snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
import https from 'node:https';
import process from 'node:process';
import type { Participant } from '../../helpers/Participant';
/**
* Helper functions for dial-in related operations.
* To be able to create a fake dial-in test that will run most of the logic for the real dial-in test.
*/
/**
* Waits for the audio from the dial-in participant.
* @param participant
*/
export async function waitForAudioFromDialInParticipant(participant: Participant) {
// waits 15 seconds for the participant to join
await participant.waitForParticipants(1, `dial-in.test.jigasi.participant.no.join.for:${
ctx.times.restAPIExecutionTS + 15_000} ms.`);
const joinedTS = performance.now();
console.log(`dial-in.test.jigasi.participant.join.after:${joinedTS - ctx.times.restAPIExecutionTS}`);
await participant.waitForIceConnected();
await participant.waitForRemoteStreams(1);
await participant.waitForSendReceiveData(20_000, 'dial-in.test.jigasi.participant.no.audio.after.join');
console.log(`dial-in.test.jigasi.participant.received.audio.after.join:${performance.now() - joinedTS} ms.`);
}
/**
* Cleans up the dial-in participant by kicking it if the local participant is a moderator.
* @param participant
*/
export async function cleanup(participant: Participant) {
// cleanup
if (await participant.isModerator()) {
const jigasiEndpointId = await participant.execute(() => APP?.conference?.listMembers()[0].getId());
await participant.getFilmstrip().kickParticipant(jigasiEndpointId);
}
}
/**
* Checks if the dial-in is enabled.
* @param participant
*/
export async function isDialInEnabled(participant: Participant) {
return await participant.execute(() => Boolean(
config.dialInConfCodeUrl && config.dialInNumbersUrl && config.hosts?.muc));
}
/**
* Sends a request to the REST API to dial in the participant using the provided pin.
* @param pin the pin to use when dialing in
*/
export async function dialIn(pin: string) {
const restUrl = process.env.DIAL_IN_REST_URL?.replace('{0}', pin);
// we have already checked in the first test that DIAL_IN_REST_URL exist so restUrl cannot be ''
const responseData: string = await new Promise((resolve, reject) => {
https.get(restUrl || '', res => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
ctx.times.restAPIExecutionTS = performance.now();
resolve(data);
});
}).on('error', err => {
console.error('dial-in.test.restAPI.request.fail');
console.error(err);
reject(err);
});
});
console.log(`dial-in.test.call_session_history_id:${JSON.parse(responseData).call_session_history_id}`);
console.log(`API response:${responseData}`);
}
export async function assertUrlDisplayed(p: Participant) {
const inviteDialog = p.getInviteDialog();
await inviteDialog.open();
await inviteDialog.waitTillOpen();
const driverUrl = await p.driver.getUrl();
expect(driverUrl.includes(await inviteDialog.getMeetingURL())).toBe(true);
await inviteDialog.clickCloseButton();
await inviteDialog.waitTillOpen(true);
}
export async function assertDialInDisplayed(p: Participant, displayed: boolean = false) {
const inviteDialog = p.getInviteDialog();
await inviteDialog.open();
await inviteDialog.waitTillOpen();
expect((await inviteDialog.getDialInNumber()).length > 0).toBe(displayed);
expect((await inviteDialog.getPinNumber()).length > 0).toBe(displayed);
}
export async function verifyMoreNumbersPage(p: Participant) {
const inviteDialog = p.getInviteDialog();
await inviteDialog.open();
await inviteDialog.waitTillOpen();
const windows = await p.driver.getWindowHandles();
expect(windows.length).toBe(1);
const meetingWindow = windows[0];
const displayedNumber = await inviteDialog.getDialInNumber();
const displayedPin = await inviteDialog.getPinNumber();
await inviteDialog.openDialInNumbersPage();
const newWindow = (await p.driver.getWindowHandles()).filter(w => w !== meetingWindow);
expect(newWindow.length).toBe(1);
const moreNumbersWindow = newWindow[0];
await p.driver.switchWindow(moreNumbersWindow);
await browser.pause(10000);
await p.driver.$('.dial-in-numbers-list').waitForExist();
const conferenceIdMessage = p.driver.$('//div[contains(@class, "pinLabel")]');
expect((await conferenceIdMessage.getText()).replace(/ /g, '').includes(displayedPin)).toBe(true);
const numbers = p.driver.$$('.dial-in-number');
const nums = await numbers.filter(
async el => (await el.getText()).trim() === displayedNumber);
expect(nums.length).toBe(1);
await p.driver.switchWindow(meetingWindow);
}

View File

@@ -0,0 +1,66 @@
import { Participant } from '../../helpers/Participant';
/**
* Toggles the mute state of a specific Meet conference participant and verifies that a specific other Meet
* conference participants sees a specific mute state for the former.
*
* @param {Participant} testee - The {@code Participant} which represents the Meet conference participant whose
* mute state is to be toggled.
* @param {Participant} observer - The {@code Participant} which represents the Meet conference participant to verify
* the mute state of {@code testee}.
* @returns {Promise<void>}
*/
export async function muteAudioAndCheck(testee: Participant, observer: Participant): Promise<void> {
await testee.getToolbar().clickAudioMuteButton();
await observer.getFilmstrip().assertAudioMuteIconIsDisplayed(testee);
await testee.getFilmstrip().assertAudioMuteIconIsDisplayed(testee);
await observer.getParticipantsPane().assertAudioMuteIconIsDisplayed(testee);
await testee.getParticipantsPane().assertAudioMuteIconIsDisplayed(testee);
}
/**
* Unmute audio, checks if the local UI has been updated accordingly and then does the verification from
* the other observer participant perspective.
* @param testee
* @param observer
*/
export async function unmuteAudioAndCheck(testee: Participant, observer: Participant) {
await testee.getNotifications().closeAskToUnmuteNotification(true);
await testee.getNotifications().closeAVModerationMutedNotification(true);
await testee.getToolbar().clickAudioUnmuteButton();
await testee.getFilmstrip().assertAudioMuteIconIsDisplayed(testee, true);
await observer.getFilmstrip().assertAudioMuteIconIsDisplayed(testee, true);
await testee.getParticipantsPane().assertAudioMuteIconIsDisplayed(testee, true);
await observer.getParticipantsPane().assertAudioMuteIconIsDisplayed(testee, true);
}
/**
* Stop the video on testee and check on observer.
* @param testee
* @param observer
*/
export async function unmuteVideoAndCheck(testee: Participant, observer: Participant): Promise<void> {
await testee.getNotifications().closeAskToUnmuteNotification(true);
await testee.getNotifications().closeAVModerationVideoMutedNotification(true);
await testee.getToolbar().clickVideoUnmuteButton();
await testee.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee, true);
await observer.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee, true);
}
/**
* Starts the video on testee and check on observer.
* @param testee
* @param observer
*/
export async function muteVideoAndCheck(testee: Participant, observer: Participant): Promise<void> {
await testee.getToolbar().clickVideoMuteButton();
await testee.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee);
await observer.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee);
}

View File

@@ -0,0 +1,155 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { joinMuc } from '../../helpers/joinMuc';
import { checkIframeApi } from './util';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Chat', () => {
let p1: Participant, p2: Participant;
it('setup', async () => {
p1 = await joinMuc({ name: 'p1', iFrameApi: true, token: testsConfig.jwt.preconfiguredToken });
if (!await checkIframeApi(p1)) {
return;
}
p2 = await joinMuc({ name: 'p2', iFrameApi: true });
await p1.switchToMainFrame();
await p2.switchToMainFrame();
});
it('send message', async () => {
await p2.getIframeAPI().addEventListener('chatUpdated');
await p2.getIframeAPI().addEventListener('incomingMessage');
await p1.getIframeAPI().addEventListener('outgoingMessage');
const testMessage = 'Hello world';
await p1.getIframeAPI().executeCommand('sendChatMessage', testMessage);
const chatUpdatedEvent: {
isOpen: boolean;
unreadCount: number;
} = await p2.driver.waitUntil(() => p2.getIframeAPI().getEventResult('chatUpdated'), {
timeout: 3000,
timeoutMsg: 'Chat was not updated'
});
expect(chatUpdatedEvent).toEqual({
isOpen: false,
unreadCount: 1
});
const incomingMessageEvent: {
from: string;
message: string;
nick: string;
privateMessage: boolean;
} = await p2.getIframeAPI().getEventResult('incomingMessage');
expect(incomingMessageEvent).toMatchObject({
from: await p1.getEndpointId(),
message: testMessage,
nick: p1.name,
privateMessage: false
});
const outgoingMessageEvent: {
message: string;
privateMessage: boolean;
} = await p1.getIframeAPI().getEventResult('outgoingMessage');
expect(outgoingMessageEvent).toEqual({
message: testMessage,
privateMessage: false
});
await p1.getIframeAPI().clearEventResults('outgoingMessage');
await p2.getIframeAPI().clearEventResults('chatUpdated');
await p2.getIframeAPI().clearEventResults('incomingMessage');
});
it('toggle chat', async () => {
await p2.getIframeAPI().executeCommand('toggleChat');
await testSendGroupMessageWithChatOpen(p1, p2);
await p1.getIframeAPI().clearEventResults('outgoingMessage');
await p2.getIframeAPI().clearEventResults('chatUpdated');
await p2.getIframeAPI().clearEventResults('incomingMessage');
});
it('private chat', async () => {
const testMessage = 'Hello private world!';
await p1.getIframeAPI().executeCommand('initiatePrivateChat', await p2.getEndpointId());
await p1.getIframeAPI().executeCommand('sendChatMessage', testMessage, await p2.getEndpointId());
const incomingMessageEvent = await p2.driver.waitUntil(
() => p2.getIframeAPI().getEventResult('incomingMessage'), {
timeout: 3000,
timeoutMsg: 'Chat was not received'
});
expect(incomingMessageEvent).toMatchObject({
from: await p1.getEndpointId(),
message: testMessage,
nick: p1.name,
privateMessage: true
});
expect(await p1.getIframeAPI().getEventResult('outgoingMessage')).toEqual({
message: testMessage,
privateMessage: true
});
await p1.getIframeAPI().executeCommand('cancelPrivateChat');
await p2.getIframeAPI().clearEventResults('chatUpdated');
await p2.getIframeAPI().clearEventResults('incomingMessage');
await testSendGroupMessageWithChatOpen(p1, p2);
});
});
/**
* Send a group message from [sender], verify that it was received correctly by [receiver].
* @param sender the Participant that sends the message.
* @param receiver the Participant that receives the message.
*/
async function testSendGroupMessageWithChatOpen(sender: Participant, receiver: Participant) {
const testMessage = 'Hello world again';
await sender.getIframeAPI().executeCommand('sendChatMessage', testMessage);
const chatUpdatedEvent: {
isOpen: boolean;
unreadCount: number;
} = await receiver.driver.waitUntil(() => receiver.getIframeAPI().getEventResult('chatUpdated'), {
timeout: 3000,
timeoutMsg: 'Chat was not updated'
});
expect(chatUpdatedEvent).toEqual({
isOpen: true,
unreadCount: 0
});
const incomingMessageEvent = await receiver.driver.waitUntil(
() => receiver.getIframeAPI().getEventResult('incomingMessage'), {
timeout: 3000,
timeoutMsg: 'Chat was not received'
});
expect(incomingMessageEvent).toMatchObject({
from: await sender.getEndpointId(),
message: testMessage,
nick: sender.name,
privateMessage: false
});
}

View File

@@ -0,0 +1,137 @@
import { isEqual } from 'lodash-es';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants } from '../../helpers/participants';
import { checkIframeApi } from './util';
setTestProperties(__filename, {
retry: true,
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Kick participants', () => {
it('joining the meeting', async () => {
await ensureTwoParticipants({}, { name: 'p1', iFrameApi: true });
const { p1, p2 } = ctx;
if (!await checkIframeApi(p1)) {
return;
}
await Promise.all([
p1.switchToMainFrame(),
p2.switchToMainFrame()
]);
expect(await p1.getIframeAPI().getEventResult('isModerator')).toBe(true);
expect(await p1.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
expect(await p2.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
});
it('kick participant', async () => {
await ctx.p2.getIframeAPI().clearEventResults('videoConferenceLeft');
await ctx.p2.getIframeAPI().addEventListener('videoConferenceLeft');
await ctx.p2.switchToMainFrame();
await ctx.p2.getIframeAPI().executeCommand('hangup');
await ctx.p2.driver.waitUntil(() =>
ctx.p2.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 4000,
timeoutMsg: 'videoConferenceLeft not received'
});
await ensureTwoParticipants({}, { name: 'p1', iFrameApi: true });
const { p1, p2, roomName } = ctx;
const p1EpId = await p1.getEndpointId();
const p2EpId = await p2.getEndpointId();
const p1DisplayName = await p1.getLocalDisplayName();
const p2DisplayName = await p2.getLocalDisplayName();
await p1.switchToMainFrame();
await p2.switchToMainFrame();
await p1.getIframeAPI().addEventListener('participantKickedOut');
await p2.getIframeAPI().addEventListener('participantKickedOut');
await p2.getIframeAPI().clearEventResults('videoConferenceLeft');
await p2.getIframeAPI().addEventListener('videoConferenceLeft');
await p1.getIframeAPI().executeCommand('kickParticipant', p2EpId);
const eventP1 = await p1.driver.waitUntil(() => p1.getIframeAPI().getEventResult('participantKickedOut'), {
timeout: 2000,
timeoutMsg: 'participantKickedOut event not received on p1 side'
});
const eventP2 = await p2.driver.waitUntil(() => p2.getIframeAPI().getEventResult('participantKickedOut'), {
timeout: 2000,
timeoutMsg: 'participantKickedOut event not received on p2 side'
});
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(() =>
p2.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 4000,
timeoutMsg: 'videoConferenceLeft not received'
});
expect(eventConferenceLeftP2).toBeDefined();
expect(eventConferenceLeftP2.roomName).toBe(roomName);
});
it('join after kick', async () => {
const { p1 } = ctx;
await p1.getIframeAPI().addEventListener('participantJoined');
// join again
await ensureTwoParticipants({}, { name: 'p1', iFrameApi: true });
const { p2 } = ctx;
await p1.switchToMainFrame();
const event = await p1.driver.waitUntil(() => 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);
});
});

View File

@@ -0,0 +1,164 @@
import { P1, P2 } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants, parseJid } from '../../helpers/participants';
import { checkIframeApi } from './util';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Participants presence', () => {
it('joining the meeting', async () => {
await ensureTwoParticipants({}, { name: 'p1', iFrameApi: true });
const { p1, p2 } = ctx;
if (!await checkIframeApi(p1)) {
return;
}
await Promise.all([
p1.switchToMainFrame(),
p2.switchToMainFrame()
]);
expect(await p1.getIframeAPI().getEventResult('isModerator')).toBe(true);
expect(await p1.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
expect(await p2.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
});
it('participants info',
async () => {
const { p1, roomName } = 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);
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);
}
);
it('participants pane', async () => {
const { p1 } = ctx;
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('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.switchToIFrame();
expect(await p1.getLocalDisplayName()).toBe(newP1Name);
expect(await p1.getFilmstrip().getRemoteDisplayName(p2EpId)).toBe(newP2Name);
});
it('hangup', async () => {
const { p1, p2, roomName } = ctx;
await p1.switchToMainFrame();
await p2.switchToMainFrame();
await p2.getIframeAPI().clearEventResults('videoConferenceLeft');
await p2.getIframeAPI().addEventListener('videoConferenceLeft');
await p2.getIframeAPI().addEventListener('readyToClose');
await p2.getIframeAPI().executeCommand('hangup');
const eventConferenceLeftP2 = await p2.driver.waitUntil(() =>
p2.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 4000,
timeoutMsg: 'videoConferenceLeft not received'
});
expect(eventConferenceLeftP2).toBeDefined();
expect(eventConferenceLeftP2.roomName).toBe(roomName);
const eventReadyToCloseP2 = await p2.driver.waitUntil(() => p2.getIframeAPI().getEventResult('readyToClose'), {
timeout: 2000,
timeoutMsg: 'readyToClose not received'
});
expect(eventReadyToCloseP2).toBeDefined();
});
it('dispose conference', async () => {
const { p1, roomName } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().clearEventResults('videoConferenceLeft');
await p1.getIframeAPI().addEventListener('videoConferenceLeft');
await p1.getIframeAPI().addEventListener('readyToClose');
await p1.getIframeAPI().executeCommand('hangup');
const eventConferenceLeft = await p1.driver.waitUntil(() =>
p1.getIframeAPI().getEventResult('videoConferenceLeft'), {
timeout: 4000,
timeoutMsg: 'videoConferenceLeft not received'
});
expect(eventConferenceLeft).toBeDefined();
expect(eventConferenceLeft.roomName).toBe(roomName);
const eventReadyToClose = await p1.driver.waitUntil(() => 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

@@ -0,0 +1,140 @@
# Second screen — manual test checklist
Companion to `secondScreen.spec.ts`. That spec covers the contract the External
API owes an embedder: that a command is answered, with which code, and that a
failure leaves no window behind. Everything below is what an automated run cannot
reach, and it is not a small set, so it is written to be reproducible rather than
left to whoever last touched the feature.
## Why these are not automated
CI runs headless with a single display. Two things follow, and only the second is
the obvious one.
Headless Chrome **does** expose `getScreenDetails`, so the feature reports itself
as supported and a command gets past the enablement gate. It is not the
"unsupported browser" case it is often assumed to be. What it cannot do is reach
the permission: `navigator.permissions.query` reports `prompt`, but no prompt is
ever raised, because a command arriving over the external API carries no
transient user activation and Chromium refuses to ask without one. The call
rejects with `NotAllowedError` in ~2ms (verified on HeadlessChrome/151), so an
enabled command in CI lands on `window-management-unavailable` almost at once,
not on `second-screen-disabled`.
That also means a prompt left unanswered is only reachable from an in-app
trigger in a real browser, which is why cases A3 and A5 below cannot be
approximated in CI at all.
And with one display there is no second screen to place a window on, so placement,
fullscreen, occupancy and every multi-display behaviour below are unreachable
regardless of permissions. Stubbing `getScreenDetails` would test the stub.
## Setup
- Chromium-based browser. The Window Management API is Chromium-only, so Firefox
and Safari are only useful for confirming the feature stays off (case A6).
- At least two physical displays. Cases D1 to D4 need one that can be undocked or
unplugged.
- `secondScreen: { enabled: true }`, either in `config.js` or as
`#config.secondScreen.enabled=true` on the URL, since the option is in
`configWhitelist`.
- For an iframe embed, the iframe needs `allow="window-management; fullscreen"`.
- **Allow popups for the origin.** A command arriving over the External API runs
with no user activation, so the popup blocker refuses the window and every such
case reports `popup-blocked` instead of what it was meant to test. An in-app
trigger is a real click and does not need this.
- **Grant window-management out of band for the API cases.** For the same reason,
Chromium will not raise the permission prompt for an API-driven open: it rejects
immediately, and the case reports `window-management-unavailable`. Grant it under
the site settings padlock first, or drive the case from an in-app trigger.
- Start each permission case from a profile where window-management has not been
answered yet. Reset it under the site settings padlock, or use a fresh profile;
the permission is sticky per origin, so a second run of A2 tests nothing.
Cases marked **[#17666]** cover behaviour the in-app triggers added. That PR
merged on 19 August, so every case here runs against master.
---
## A. Opening, permissions and placement
**A2 to A5c have to be driven by an in-app trigger, not by the External API**, and
are therefore **[#17666]** cases whatever else they test. Only a real click carries
the user activation Chromium requires before it will raise the permission prompt;
over the API the call rejects at once and the case never reaches what it was
written for.
| # | Steps | Expected |
|---|---|---|
| A1 | On a granted profile, `setSecondScreen` with `{ id: 'a', source: { role: 'stage' } }` | Window opens on an external display, fullscreen, showing the active speaker. `secondScreenSourceChanged` fires with `id: 'a'`. |
| A2 | On an ungranted profile, click an in-app trigger and press **Allow** promptly | Prompt appears, window opens and moves onto the external display. One `secondScreenSourceChanged`. |
| A3 | Same as A2, but wait ~30s before pressing Allow | The window still opens and places correctly. It must not be blocked by the popup blocker, and no error is reported. This is the transient-activation case: the open runs before anything is awaited. **[#17666]** The window renders its content on the meeting's own screen while the prompt is up, and jumps to the external display on Allow; it is not blank while waiting. |
| A4 | Same as A2, but press **Block** | `secondScreenError` with `window-management-unavailable`, and nothing after it. No window is left on screen, and no `secondScreenClosed` follows the error: reporting the error alone is master's contract for a failed open, and an embedder that reopens on `secondScreenClosed` would loop on the extra event. **[#17666]** |
| A5 | Same as A2, but dismiss nothing and leave the prompt open | **[#17666]** The window renders on the meeting's own screen meanwhile, so it looks like it worked; after 30s `secondScreenError` with `window-management-unavailable`, the window closes, and again no `secondScreenClosed` follows. The bound is what keeps an unanswered prompt from parking the open forever. |
| A5b | During A5, while the prompt is still up, click the trigger again to cancel | **[#17666]** The window closes at once and `secondScreenClosed` is reported. It must not stay up until the prompt is answered, and the trigger must not stay inverted. |
| A5c | During A5, while the prompt is still up, send the same id again | **[#17666]** The existing window re-sources in place. It must not open a second window and must not return silently. |
| A6 | Repeat A1 in Firefox or Safari | `secondScreenError` with `second-screen-disabled`. No window. |
| A7 | With popups blocked for the site, repeat A1 | `secondScreenError` with `popup-blocked`. |
| A8 | On a profile where window-management was previously **denied**, click an in-app trigger | **[#17666]** `secondScreenError` with `window-management-unavailable`, and no window is left behind. Expect a brief flash: the rejection is not acted on until the window is registered, so it opens, loads and renders before being torn down. Shortening that needs the denied state to be known before the open, which `preloadScreenDetails` could cache. |
## B. Sources and live behaviour
| # | Steps | Expected |
|---|---|---|
| B1 | `{ role: 'stage' }`, then have a different participant speak | The window follows the new active speaker without a re-send. A `secondScreenSourceChanged` fires per change, with the new `participantId`. |
| B2 | `{ role: 'tile' }`, then have someone join and someone leave | The grid re-lays out both times. |
| B3 | `{ role: 'screenshare' }` while one person shares | The share renders full-bleed. |
| B4 | `{ role: 'screenshare', participant: X }` with **two** shares live | Specifically X's share, not the stage layout. Note this differs from pre-#17666 behaviour, where the participant was ignored. |
| B5 | `{ role: 'whiteboard' }` | The whiteboard renders and is drawable from the meeting window, updating on the second screen. |
| B6 | `{ role: 'sharedvideo' }` while a video is shared | The video plays. **Audio must come from the meeting window only** — confirm it is not doubled or echoed. |
| B7 | Pause the shared video from the meeting | The second screen keeps showing it; the window must not tear down. |
| B8 | `{ participant: X }` for a participant with video, then mute their camera | Video, then their avatar. No blank window. |
| B9 | `{ participant: X, media: 'desktop' }` | Their screen rather than their camera. |
| B10 | Reuse the same `id` with a different `source` | The window updates in place. It must not close and reopen, and must not move screens. |
## C. Closing
| # | Steps | Expected |
|---|---|---|
| C1 | `setSecondScreen` with the same `id` and no `source` | Window closes, `secondScreenClosed` fires once. |
| C2 | Close the popup by hand on the other display | `secondScreenClosed` fires. Re-sending the same id opens a fresh window. |
| C3 | End the conference with a window open | Window closes, `secondScreenClosed` fires. |
| C4 | Reload the meeting tab with a window open | The orphan window closes rather than being left on the display. |
| C5 | Send a close for an id that was never opened | Nothing happens, and no `secondScreenClosed` is fabricated. |
## D. Multiple displays and occupancy — **[#17666]**
These are the cases the positional occupancy logic exists for. Three displays are
ideal; two will exercise D1 and D2.
| # | Steps | Expected |
|---|---|---|
| D1 | Meeting on A, externals B and C. Trigger a send twice | The two windows land on B and C, one each, never stacked. |
| D2 | With windows on B and C, close B's popup by hand, then undock B. Send again | The send re-sources the surviving window rather than opening a second one on top of it. |
| D3 | With windows on B and C, undock C (its window relocates onto A), then redock C. Send again | A window opens on the now-free C. It must **not** silently re-source the window sitting over the meeting. |
| D4 | Undock C so its window relocates onto A, leaving no free external screen. Send again | The send takes over the window on B, not the one overlapping the meeting. |
| D5 | Start with only the laptop panel, send once, then dock an external monitor and send again | The second send opens on the newly attached monitor, not over the first window. |
| D6 | Drag a second-screen window to a different display by hand, then send again | Occupancy follows where the window actually is, not where it was opened. |
## E. In-app triggers — **[#17666]**
| # | Steps | Expected |
|---|---|---|
| E1 | With the feature **disabled**, hover a screenshare tile and the shared-video tile | No trigger, and no invisible hit area: clicking the top-right corner of the tile still pins as it always did. |
| E2 | Hover a screenshare tile with the feature enabled | Trigger appears; clicking sends that share to a second screen. |
| E3 | Click the same trigger again | The window closes. |
| E4 | Send a screenshare, then have the sharer stop sharing | The window closes on its own and reports `secondScreenClosed`, about 5s later. The tile and its trigger are gone, so nothing else could have closed it. |
| E5 | Send a participant, then have them leave | Same as E4, including the delay. |
| E6 | Send the shared video, then stop the share | The window closes at once: the shared video is not participant-backed, so it does not wait. |
| E7 | Send the whiteboard from its context menu, then close the whiteboard | Same as E6, at once. The window must not be left showing the whiteboard placeholder. |
| E7b | Send a participant, then take their tab offline in DevTools for ~2s and let it recover | The window survives. This is the case the 5s grace exists for: the participant is briefly absent, and closing on absence alone would destroy a window with no way to get it back. Best-effort, since it needs the client to resume the same session rather than rejoin as a new one; a full reload is a new id and the window closes correctly after the grace. |
| E8 | Start a send, and while the permission prompt is still open, click the trigger again to cancel | The window closes. It must not first fly to the other display and then vanish. |
| E9 | Open a window through the External API, then use an in-app trigger | The trigger opens its own window; it must never take over or close the embedder's. |
## F. Sanity
| # | Steps | Expected |
|---|---|---|
| F1 | With a second screen open, check the participant count in the meeting | Unchanged. The window is a view, not a second join. |
| F2 | Watch bandwidth/stats with a stage window open | No duplicated media subscription for the same source. |
| F3 | Leave a window open for several minutes | No drift, no memory growth, no repeated re-mounts in the console. |

View File

@@ -0,0 +1,240 @@
import { expect } from '@wdio/globals';
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant } from '../../helpers/participants';
import { checkIframeApi } from './util';
setTestProperties(__filename, {
usesBrowsers: [ 'p1' ]
});
/**
* Every code the feature can report. Which one comes back depends on the
* environment, so the tests below assert membership of this set rather than a
* specific value, except where the outcome is deterministic.
*
* Placement on a real second display and the fullscreen behaviour are not
* covered here at all: both need hardware and a granted permission that CI does
* not have, and stubbing the Window Management API would test the stub rather
* than the feature. Those cases are written up as a reproducible checklist in
* secondScreen.manual.md, alongside this file.
*/
const ERROR_CODES = [
'second-screen-disabled',
'popup-blocked',
'window-management-unavailable',
'window-load-failed',
'window-setup-failed'
];
const SCREEN_ID = 'test-screen';
/**
* Waits briefly for an event that is expected NOT to arrive, and returns
* whatever the API recorded for it. {@code getEventResult} returns false when an
* event has never fired, so a falsy result is the assertion this supports.
*
* @param {Object} p - The participant.
* @param {string} event - The event name.
* @returns {Promise<any>}
*/
async function readEventAfterSettling(p: Participant, event: string) {
await p.driver.pause(2000);
return await p.getIframeAPI().getEventResult(event);
}
describe('setSecondScreen iframe API command', () => {
it('reports second-screen-disabled when the feature is off', async () => {
await ensureOneParticipant({}, { name: 'p1', iFrameApi: true });
const { p1 } = ctx;
if (!await checkIframeApi(p1)) {
return;
}
await p1.switchToMainFrame();
await p1.getIframeAPI().addEventListener('secondScreenError');
const handlesBefore = (await p1.driver.getWindowHandles()).length;
await p1.getIframeAPI().executeCommand('setSecondScreen', {
id: SCREEN_ID,
source: { role: 'stage' }
});
const event = await p1.driver.waitUntil(
() => p1.getIframeAPI().getEventResult('secondScreenError'), {
timeout: 5000,
timeoutMsg: 'secondScreenError was not received for a disabled feature'
});
// The feature is off by default, so this outcome does not depend on the
// browser or on any permission: it is the one fully deterministic path.
expect(event.id).toBe(SCREEN_ID);
expect(event.error).toBe('second-screen-disabled');
// And it must fail without leaving a window behind.
expect((await p1.driver.getWindowHandles()).length).toBe(handlesBefore);
});
it('does not report a close for a window that was never open', async () => {
const { p1 } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().addEventListener('secondScreenClosed');
await p1.getIframeAPI().clearEventResults('secondScreenClosed');
// A command with no source is the documented way to close a window. For
// an id that has none, it is a no-op: nothing was closed, so nothing is
// reported.
await p1.getIframeAPI().executeCommand('setSecondScreen', { id: 'never-opened' });
expect(await readEventAfterSettling(p1, 'secondScreenClosed')).toBeFalsy();
});
it('treats a source with neither role nor participant as a close', async () => {
const { p1 } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().clearEventResults('secondScreenError');
await p1.getIframeAPI().clearEventResults('secondScreenClosed');
await p1.getIframeAPI().executeCommand('setSecondScreen', {
id: 'never-opened',
source: {}
});
// An empty source is a close request, not an attempt to open something
// unrenderable, so the disabled feature is never consulted and no error
// is reported.
expect(await readEventAfterSettling(p1, 'secondScreenError')).toBeFalsy();
expect(await p1.getIframeAPI().getEventResult('secondScreenClosed')).toBeFalsy();
});
it('always reports an outcome once the feature is enabled', async () => {
await ctx.p1.hangup();
await ensureOneParticipant({
configOverwrite: {
secondScreen: { enabled: true }
}
}, { name: 'p1', iFrameApi: true });
const { p1 } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().addEventListener('secondScreenError');
await p1.getIframeAPI().addEventListener('secondScreenSourceChanged');
await p1.getIframeAPI().clearEventResults('secondScreenError');
await p1.getIframeAPI().clearEventResults('secondScreenSourceChanged');
await p1.getIframeAPI().executeCommand('setSecondScreen', {
id: SCREEN_ID,
source: { role: 'stage' }
});
// What happens next depends on the environment, and both outcomes are
// acceptable. What the feature must never do is accept the command and
// then say nothing, which is what this pins down.
//
// Headless Chrome is not the "unsupported browser" case it is sometimes
// assumed to be: it does expose getScreenDetails, so the feature reports
// itself as supported and the command gets past the enablement gate.
// The permission reports `prompt`, but no prompt is ever raised here,
// because a command arriving over the external API carries no user
// activation and Chromium refuses to ask without one. The call rejects
// with NotAllowedError in a couple of milliseconds (verified on 151), so
// this lands on window-management-unavailable almost at once rather than
// waiting out the permission bound.
const outcome = await p1.driver.waitUntil(async () => {
const error = await p1.getIframeAPI().getEventResult('secondScreenError');
const changed = await p1.getIframeAPI().getEventResult('secondScreenSourceChanged');
return error || changed || false;
}, {
timeout: 15000,
timeoutMsg: 'setSecondScreen reported neither a source change nor an error'
});
expect(outcome.id).toBe(SCREEN_ID);
if (outcome.error) {
expect(ERROR_CODES).toContain(outcome.error);
// A failed open must not leave the window it could not finish
// setting up, since nothing in the meeting could close it afterwards.
expect((await p1.driver.getWindowHandles()).length).toBe(1);
} else {
expect(outcome.source.role).toBe('stage');
}
});
it('reports the source it resolved rather than only what was asked for', async function() {
const { p1 } = ctx;
const changed = await p1.getIframeAPI().getEventResult('secondScreenSourceChanged');
if (!changed) {
// secondScreenSourceChanged never fired, so no window was ever
// opened/registered in this environment. Skip rather than return:
// headless cannot grant the window-management permission, so this
// would otherwise report green without ever touching the payload
// it is named for.
//
// When changed is truthy the window may since have closed itself
// (see the next test) - that does not matter here, since this only
// checks the shape of the payload already recorded, not that a
// window is still open.
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.skip();
}
// participantId is what the role resolved to, and is null when nothing
// backs it yet. Either way the field is part of the payload.
expect(changed).toHaveProperty('participantId');
expect(changed).toHaveProperty('source');
});
it('closes an open window and reports it', async function() {
const { p1 } = ctx;
if ((await p1.driver.getWindowHandles()).length === 1) {
// Nothing to close: either no window was ever opened in this
// environment, or it already closed itself asynchronously (e.g. a
// window-management-unavailable failure resolving shortly after
// secondScreenSourceChanged had already fired) before this test
// got a chance to close it. The live handle count is the direct
// signal for "is there a window to close", unlike the cached event
// above, which stays truthy even once that window is long gone -
// that is what makes this a skip rather than a pass in practice on
// every environment CI runs today; see secondScreen.manual.md for
// the coverage that would otherwise exercise this path.
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.skip();
}
await p1.switchToMainFrame();
await p1.getIframeAPI().addEventListener('secondScreenClosed');
await p1.getIframeAPI().clearEventResults('secondScreenClosed');
await p1.getIframeAPI().executeCommand('setSecondScreen', { id: SCREEN_ID });
const event = await p1.driver.waitUntil(
() => p1.getIframeAPI().getEventResult('secondScreenClosed'), {
timeout: 5000,
timeoutMsg: 'secondScreenClosed was not received'
});
expect(event.id).toBe(SCREEN_ID);
// The window itself has to be gone, not just the redux entry.
await p1.driver.waitUntil(
async () => (await p1.driver.getWindowHandles()).length === 1, {
timeout: 5000,
timeoutMsg: 'the second-screen window was not closed'
});
});
});

View File

@@ -0,0 +1,197 @@
import { expect } from '@wdio/globals';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant } from '../../helpers/participants';
import { checkIframeApi } from './util';
setTestProperties(__filename, {
usesBrowsers: [ 'p1' ]
});
const PILL_SELECTOR = '[data-testid="time-timer-pill"]';
const ENDED_NOTIFICATION_SELECTOR = '[data-testid="timeTimer.endedTitle"]';
const EXPIRED_BORDER_SELECTOR = '#videospace.timer-expired';
// The duration these tests push through the iframe API. Distinct from any
// server-enforced limit, so assertions can tell the two apart on a deployment
// that runs mod_time_restricted.
const PUSHED_DURATION = 1800;
describe('setMeetingTimer iframe API command', () => {
it('does not show the timer until a duration is pushed', async () => {
// The default test config disables the timer, so enable it explicitly.
// `suppressForSeconds: 0` is pinned rather than left to the deployment:
// a target that configures a window (alpha sets 180) would otherwise
// hold the pill back and every pill assertion below would read as a
// missing timer. The suppression window has its own test further down,
// which sets the value it needs.
await ensureOneParticipant({
configOverwrite: {
timeTimer: {
enabled: true,
suppressForSeconds: 0
}
}
}, { name: 'p1', iFrameApi: true });
const { p1 } = ctx;
if (!await checkIframeApi(p1)) {
return;
}
// Inside the iframe — nothing has pushed a duration through the API yet.
// This keys off the duration rather than asserting the pill is absent:
// a deployment that enforces its own limit (mod_time_restricted pushes
// one to every occupant on join) legitimately has a timer running here,
// and the point of this case is that the API has not set one.
await p1.switchToIFrame();
expect(await p1.execute(() => APP.store.getState()['features/time-timer'].durationSeconds))
.not.toBe(PUSHED_DURATION);
});
it('shows the timer pill when a duration is pushed via the iframe API', async () => {
const { p1 } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().executeCommand('setMeetingTimer', {
duration: 1800, // 30 min
elapsed: 0
});
await p1.switchToIFrame();
await expect(await p1.driver.$(PILL_SELECTOR)).toBeDisplayed();
});
it('shows a running (not expired) timer when joining before the scheduled start', async () => {
const { p1 } = ctx;
// Negative elapsed === the meeting starts in 10 minutes. The timer must
// show as running (anchored to the scheduled start), NOT expired — an
// early joiner should never see the red over-schedule state.
await p1.switchToMainFrame();
await p1.getIframeAPI().executeCommand('setMeetingTimer', {
duration: 1800, // 30 min
elapsed: -600 // joined 10 min before the scheduled start
});
await p1.switchToIFrame();
await expect(await p1.driver.$(PILL_SELECTOR)).toBeDisplayed();
// No expired border and no ended notification while still before / within
// the scheduled window.
await expect(await p1.driver.$(EXPIRED_BORDER_SELECTOR)).not.toExist();
await expect(await p1.driver.$(ENDED_NOTIFICATION_SELECTOR)).not.toExist();
});
it('flips to the expired state when an over-schedule timer is pushed', async () => {
const { p1 } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().executeCommand('setMeetingTimer', {
duration: 1800, // 30 min
elapsed: 2520 // already 12 minutes past the scheduled end
});
await p1.switchToIFrame();
// Pill stays visible …
await expect(await p1.driver.$(PILL_SELECTOR)).toBeDisplayed();
// … the videospace gets the red expired-border class …
await p1.driver.waitUntil(
async () => await p1.driver.$(EXPIRED_BORDER_SELECTOR).isExisting(),
{ timeout: 3000, timeoutMsg: 'expired border did not appear' });
// … and the sticky "Timer ended" notification appears.
await p1.driver.waitUntil(
async () => await p1.driver.$(ENDED_NOTIFICATION_SELECTOR).isExisting(),
{ timeout: 3000, timeoutMsg: 'timer-ended notification did not appear' });
});
it('clears the pill, border and notification when the timer is cleared', async () => {
const { p1 } = ctx;
await p1.switchToMainFrame();
// No duration => clear.
await p1.getIframeAPI().executeCommand('setMeetingTimer', {});
await p1.switchToIFrame();
await p1.driver.waitUntil(
async () => !await p1.driver.$(PILL_SELECTOR).isDisplayed(),
{ timeout: 3000, timeoutMsg: 'pill did not disappear on clear' });
await p1.driver.waitUntil(
async () => !await p1.driver.$(EXPIRED_BORDER_SELECTOR).isExisting(),
{ timeout: 3000, timeoutMsg: 'expired border did not clear' });
await p1.driver.waitUntil(
async () => !await p1.driver.$(ENDED_NOTIFICATION_SELECTOR).isExisting(),
{ timeout: 3000, timeoutMsg: 'timer-ended notification did not clear' });
});
it('holds the pill back for the configured suppression window', async () => {
// Rejoin with a suppression window: the timer runs from the moment a
// duration is known, but stays off screen until the meeting is that far
// along. This is what lets `mod_time_restricted` push the limit on join
// while the countdown only surfaces part-way through.
await ctx.p1.hangup();
await ensureOneParticipant({
configOverwrite: {
timeTimer: {
enabled: true,
suppressForSeconds: 600 // 10 min
}
}
}, { name: 'p1', iFrameApi: true });
const { p1 } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().executeCommand('setMeetingTimer', {
duration: 1800, // 30 min
elapsed: 0
});
await p1.switchToIFrame();
// Inside the window — a duration is known and the timer is running, but
// the pill must not be on screen yet.
await p1.driver.pause(1000);
await expect(await p1.driver.$(PILL_SELECTOR)).not.toBeDisplayed();
// Past the window (elapsed is measured from the scheduled start, so this
// is also the late-joiner case) — the pill appears.
await p1.switchToMainFrame();
await p1.getIframeAPI().executeCommand('setMeetingTimer', {
duration: 1800,
elapsed: 900 // 15 min in, past the 10 min suppression window
});
await p1.switchToIFrame();
await expect(await p1.driver.$(PILL_SELECTOR)).toBeDisplayed();
});
it('ignores the command when the feature is disabled', async () => {
// Hangup and rejoin the same conference with the default test config,
// which disables the timer, so we can prove the command is a no-op on
// opt-out deployments.
await ctx.p1.hangup();
await ensureOneParticipant({}, { name: 'p1', iFrameApi: true });
const { p1 } = ctx;
await p1.switchToMainFrame();
await p1.getIframeAPI().executeCommand('setMeetingTimer', {
duration: 1800,
elapsed: 0
});
await p1.switchToIFrame();
// Give the dispatch a moment, then assert the pill never appeared.
await p1.driver.pause(1000);
await expect(await p1.driver.$(PILL_SELECTOR)).not.toBeDisplayed();
});
});

View File

@@ -0,0 +1,13 @@
import type { Participant } from '../../helpers/Participant';
import { expectations } from '../../helpers/expectations';
export async function checkIframeApi(p: Participant) {
const iframeEnabled = !await p.execute(() => config.disableIframeAPI);
expect(iframeEnabled).toBe(expectations.iframe.enabled);
if (!iframeEnabled) {
ctx.skipSuiteTests = 'The iFrame API is disabled';
}
return iframeEnabled;
}

View File

@@ -0,0 +1,71 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import WebhookProxy from '../../helpers/WebhookProxy';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
import { fetchJson } from '../../helpers/utils';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2' ]
});
describe('JaaS CHAT_UPLOADED webhook.', () => {
const tenant = testsConfig.jaas.tenant;
const customerId = tenant?.replace('vpaas-magic-cookie-', '');
let p1: Participant, p2: Participant;
let webhooksProxy: WebhookProxy;
let fqn: string;
it('setup', async () => {
const room = ctx.roomName;
webhooksProxy = ctx.webhooksProxy;
p1 = await joinJaasMuc({ name: 'p1', token: t({ room }) });
p2 = await joinJaasMuc({ name: 'p2', token: t({ room }) });
fqn = `${testsConfig.jaas.tenant}/${room}`;
});
it('test webhook', async () => {
await p1.getChatPanel().sendMessage('foo');
await p2.getChatPanel().sendMessage('bar');
await p1.getChatPanel().sendMessage('baz');
await p1.hangup();
await p2.hangup();
const event: {
appId: string;
customerId: string;
data: {
preAuthenticatedLink: string;
};
eventType: string;
fqn: string;
} = await webhooksProxy.waitForEvent('CHAT_UPLOADED');
expect(event.appId).toBe(tenant);
expect(event.customerId).toBe(customerId);
expect(event.data.preAuthenticatedLink).toBeDefined();
expect(event.eventType).toBe('CHAT_UPLOADED');
expect(event.fqn).toBe(fqn);
const uploadedChat: any = await fetchJson(event.data.preAuthenticatedLink);
expect(uploadedChat.meetingFqn).toBe(fqn);
expect(uploadedChat.messageType).toBe('CHAT');
const messages: any[] = uploadedChat.messages;
expect(messages).toBeDefined();
expect(messages.length).toBe(3);
console.log(JSON.stringify(messages));
expect(messages.some(m => m.name === 'p1' && m.content === 'foo')).toBe(true);
expect(messages.some(m => m.name === 'p2' && m.content === 'bar')).toBe(true);
expect(messages.some(m => m.name === 'p1' && m.content === 'baz')).toBe(true);
messages.forEach(m => {
expect(typeof m.timestamp).toBe('number');
});
});
});

View File

@@ -0,0 +1,82 @@
import type { Participant } from '../../../helpers/Participant';
import { setTestProperties } from '../../../helpers/TestProperties';
import { config as testsConfig } from '../../../helpers/TestsConfig';
import WebhookProxy from '../../../helpers/WebhookProxy';
import { expectations } from '../../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../../helpers/jaas';
import {
assertDialInDisplayed, assertUrlDisplayed,
dialIn,
isDialInEnabled, verifyMoreNumbersPage,
} from '../../helpers/DialIn';
import { verifyEndedWebhook, verifyStartedWebhooks, waitForMedia } from './util';
setTestProperties(__filename, {
useJaas: true,
useWebhookProxy: true
});
describe('Dial-in', () => {
let p1: Participant, webhooksProxy: WebhookProxy;
const customerId: string = testsConfig.jaas.customerId || '';
it('setup', async () => {
const room = ctx.roomName;
if (!process.env.DIAL_IN_REST_URL) {
ctx.skipSuiteTests = 'DIAL_IN_REST_URL is not set.';
return;
}
p1 = await joinJaasMuc({ name: 'p1', token: t({ room, moderator: true }) });
webhooksProxy = ctx.webhooksProxy;
if (!webhooksProxy) {
console.error('WebhooksProxy is not available, will not verify webhooks.');
}
expect(await p1.isInMuc()).toBe(true);
if (expectations.dialIn.enabled !== null) {
expect(await isDialInEnabled(p1)).toBe(expectations.dialIn.enabled);
}
expect(customerId).toBeDefined();
});
it ('Invite UI', async () => {
await assertUrlDisplayed(p1);
if (expectations.dialIn.enabled !== null) {
await assertDialInDisplayed(p1, expectations.dialIn.enabled);
}
if (expectations.dialIn.moreNumbersPage) {
// TODO: assert the page is NOT shown when the expectation is false.
await verifyMoreNumbersPage(p1);
}
});
it('dial-in', async () => {
const dialInPin = await p1.getDialInPin();
expect(dialInPin.length).toBeGreaterThanOrEqual(8);
await dialIn(dialInPin);
await waitForMedia(p1);
let startedPayload: any;
if (webhooksProxy) {
startedPayload
= await verifyStartedWebhooks(webhooksProxy, 'in', 'DIAL_IN_STARTED', customerId);
}
const endpointId = await p1.execute(() => APP?.conference?.listMembers()[0].getId());
await p1.getFilmstrip().kickParticipant(endpointId);
if (webhooksProxy) {
await verifyEndedWebhook(webhooksProxy, 'DIAL_IN_ENDED', customerId, startedPayload);
}
await p1.hangup();
});
});

View File

@@ -0,0 +1,50 @@
import type { Participant } from '../../../helpers/Participant';
import { setTestProperties } from '../../../helpers/TestProperties';
import { config as testsConfig } from '../../../helpers/TestsConfig';
import WebhookProxy from '../../../helpers/WebhookProxy';
import { joinJaasMuc, generateJaasToken as t } from '../../../helpers/jaas';
import { verifyEndedWebhook, verifyStartedWebhooks, waitForMedia } from './util';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true
});
describe('Dial-out', () => {
let p1: Participant, webhooksProxy: WebhookProxy;
const dialOutUrl = process.env.DIAL_OUT_URL || '';
const customerId = testsConfig.jaas.customerId || '';
it('setup', async () => {
const room = ctx.roomName;
if (!dialOutUrl) {
ctx.skipSuiteTests = 'DIAL_OUT_URL is not set.';
return;
}
webhooksProxy = ctx.webhooksProxy;
p1 = await joinJaasMuc({ name: 'p1', iFrameApi: true, token: t({ room, moderator: true }) });
expect(await p1.isInMuc()).toBe(true);
expect(Boolean(await p1.execute(() => config.dialOutAuthUrl))).toBe(true);
});
it('dial-out', async () => {
await p1.switchToMainFrame();
await p1.getIframeAPI().invitePhone(dialOutUrl);
await p1.switchToIFrame();
await p1.waitForParticipants(1);
await waitForMedia(p1);
const startedPayload
= await verifyStartedWebhooks(webhooksProxy, 'out', 'DIAL_OUT_STARTED', customerId);
const endpointId = await p1.execute(() => APP?.conference?.listMembers()[0].getId());
await p1.getFilmstrip().kickParticipant(endpointId);
await verifyEndedWebhook(webhooksProxy, 'DIAL_OUT_ENDED', customerId, startedPayload);
});
});

View File

@@ -0,0 +1,79 @@
import type { Participant } from '../../../helpers/Participant';
import { setTestProperties } from '../../../helpers/TestProperties';
import { config as testsConfig } from '../../../helpers/TestsConfig';
import WebhookProxy from '../../../helpers/WebhookProxy';
import { joinJaasMuc, generateJaasToken as t } from '../../../helpers/jaas';
import { waitForMedia } from './util';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
});
describe('SIP jibri invite', () => {
let p1: Participant, webhooksProxy: WebhookProxy;
const customerId = testsConfig.jaas.customerId || '';
const dialOutUrl = process.env.SIP_JIBRI_DIAL_OUT_URL || '';
it('setup', async () => {
const room = ctx.roomName;
if (!dialOutUrl) {
ctx.skipSuiteTests = 'SIP_JIBRI_DIAL_OUT_URL is not set.';
return;
}
p1 = await joinJaasMuc({ name: 'p1', iFrameApi: true, token: t({ room, moderator: true }) });
webhooksProxy = ctx.webhooksProxy;
expect(await p1.isInMuc()).toBe(true);
expect(Boolean(await p1.execute(() => config.inviteServiceUrl))).toBe(true);
});
it('sip jibri', async () => {
await p1.switchToMainFrame();
await p1.getIframeAPI().inviteSIP(dialOutUrl);
await p1.switchToIFrame();
await p1.waitForParticipants(1);
await waitForMedia(p1);
const startedEvent: {
customerId: string;
data: {
participantFullJid: string;
participantId: string;
participantJid: string;
sipAddress: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('SIP_CALL_OUT_STARTED');
expect('SIP_CALL_OUT_STARTED').toBe(startedEvent.eventType);
expect(startedEvent.data.sipAddress).toBe(`sip:${process.env.SIP_JIBRI_DIAL_OUT_URL}`);
expect(startedEvent.customerId).toBe(customerId);
const endpointId = await p1.execute(() => APP?.conference?.listMembers()[0].getId());
await p1.getFilmstrip().kickParticipant(endpointId);
const endedEvent: {
customerId: string;
data: {
direction: string;
participantFullJid: string;
participantId: string;
participantJid: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('SIP_CALL_OUT_ENDED');
expect('SIP_CALL_OUT_ENDED').toBe(endedEvent.eventType);
expect(endedEvent.customerId).toBe(customerId);
expect(endedEvent.data.participantFullJid).toBe(startedEvent.data.participantFullJid);
expect(endedEvent.data.participantId).toBe(startedEvent.data.participantId);
expect(endedEvent.data.participantJid).toBe(startedEvent.data.participantJid);
});
});

View File

@@ -0,0 +1,81 @@
import { Participant } from '../../../helpers/Participant';
import WebhookProxy from '../../../helpers/WebhookProxy';
interface IStartedWebhookPayload {
direction: string;
participantFullJid: string;
participantId: string;
participantJid: string;
}
/**
* Checks the dial events for a participant and clean up at the end.
* @param webhooksProxy
* @param startedEventName
* @param direction
* @param customerId
*/
export async function verifyStartedWebhooks(
webhooksProxy: WebhookProxy,
direction: 'in' | 'out',
startedEventName: string,
customerId: string): Promise<IStartedWebhookPayload> {
const startedEvent: {
customerId: string;
data: IStartedWebhookPayload;
eventType: string;
} = await webhooksProxy.waitForEvent(startedEventName);
expect(startedEventName).toBe(startedEvent.eventType);
expect(startedEvent.data.direction).toBe(direction);
expect(startedEvent.customerId).toBe(customerId);
const usageEvent: {
customerId: string;
data: any;
eventType: string;
} = await webhooksProxy.waitForEvent('USAGE');
expect('USAGE').toBe(usageEvent.eventType);
expect(usageEvent.customerId).toBe(customerId);
expect(usageEvent.data.some((el: any) =>
el.participantId === startedEvent.data.participantId && el.callDirection === direction)).toBe(true);
return startedEvent.data;
}
export async function verifyEndedWebhook(
webhooksProxy: WebhookProxy,
endedEventName: string,
customerId: string,
startedPayload: IStartedWebhookPayload) {
const endedEvent: {
customerId: string;
data: {
direction: string;
participantFullJid: string;
participantId: string;
participantJid: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent(endedEventName);
expect(endedEventName).toBe(endedEvent.eventType);
expect(endedEvent.customerId).toBe(customerId);
expect(endedEvent.data.participantFullJid).toBe(startedPayload.participantFullJid);
expect(endedEvent.data.participantId).toBe(startedPayload.participantId);
expect(endedEvent.data.participantJid).toBe(startedPayload.participantJid);
}
/**
* Wait until there is at least one remote participant, ICE is connected, the participant has a stream, and data is
* both received and sent.
*/
export async function waitForMedia(p: Participant) {
await p.waitForParticipants(1);
await p.waitForIceConnected();
await p.waitForRemoteStreams(1);
await p.waitForSendReceiveData(20_000);
}

View File

@@ -0,0 +1,172 @@
import path from 'node:path';
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { expectations } from '../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
useJaas: true,
usesBrowsers: [ 'p1', 'p2' ]
});
// __dirname-based (not CWD-relative) - this is a local path read on the machine running the
// tests (uploaded to the remote grid session via wdio's own uploadFile), so it needs to resolve
// correctly regardless of whether the process's CWD is the repo root or tests/ itself.
const TEST_FILE_PATH = path.join(__dirname, '..', '..', 'resources', 'test-upload.txt');
const TEST_FILE_NAME = 'test-upload.txt';
describe('File sharing', () => {
let p1: Participant, p2: Participant;
let fileSharingEnabled: boolean;
it('setup', async () => {
const room = ctx.roomName;
p1 = await joinJaasMuc({
name: 'p1',
token: t({ room, features: { 'file-upload': 'true' } })
});
fileSharingEnabled = await p1.execute(
() => Boolean((window as any).config?.fileSharing?.enabled && (window as any).config?.fileSharing?.apiUrl)
);
expect(fileSharingEnabled).toBe(expectations.jaas.fileSharingEnabled);
});
it('upload button enabled with file-upload feature', async () => {
if (!fileSharingEnabled) {
return;
}
const panel = p1.getFileSharingPanel();
await panel.open();
expect(await panel.isUploadButtonEnabled()).toBe(true);
});
it('upload button disabled without file-upload feature', async () => {
if (!fileSharingEnabled) {
return;
}
p2 = await joinJaasMuc({ name: 'p2', token: t({ room: ctx.roomName }) });
const panel = p2.getFileSharingPanel();
await panel.open();
expect(await panel.isUploadButtonEnabled()).toBe(false);
});
it('user with file-upload can delete files uploaded by another participant', async () => {
if (!fileSharingEnabled) {
return;
}
// p1 (with file-upload) uploads a file
const p1Panel = p1.getFileSharingPanel();
await p1Panel.open();
await p1Panel.uploadFile(TEST_FILE_PATH);
await p1Panel.waitForFile(TEST_FILE_NAME);
// p2 (joined earlier without file-upload) rejoins with file-upload to test deletion of p1's file
p2 = await joinJaasMuc({
name: 'p2',
token: t({ room: ctx.roomName, features: { 'file-upload': 'true' } })
});
const p2Panel = p2.getFileSharingPanel();
await p2Panel.open();
await p2Panel.waitForFile(TEST_FILE_NAME);
// p2 should be able to see and use the remove button for p1's file
expect(await p2Panel.canRemoveFile(TEST_FILE_NAME)).toBe(true);
await p2Panel.removeFile(TEST_FILE_NAME);
await p1Panel.waitForFileGone(TEST_FILE_NAME);
});
it('user without file-upload can download but not delete files', async () => {
if (!fileSharingEnabled) {
return;
}
// p1 (with file-upload) uploads a fresh file
const p1Panel = p1.getFileSharingPanel();
await p1Panel.open();
await p1Panel.uploadFile(TEST_FILE_PATH);
await p1Panel.waitForFile(TEST_FILE_NAME);
// p2 rejoins without file-upload
p2 = await joinJaasMuc({ name: 'p2', token: t({ room: ctx.roomName }) });
const p2Panel = p2.getFileSharingPanel();
await p2Panel.open();
await p2Panel.waitForFile(TEST_FILE_NAME);
// Download button should be present, remove button should not
expect(await p2Panel.canDownloadFile(TEST_FILE_NAME)).toBe(true);
expect(await p2Panel.canRemoveFile(TEST_FILE_NAME)).toBe(false);
});
it('dragging into conference opens file sharing tab', async () => {
if (!fileSharingEnabled) {
return;
}
// p1 already has file-upload feature
const panel = p1.getFileSharingPanel();
// Ensure chat is closed before starting
if (await panel.isChatOpen()) {
await p1.getToolbar().clickCloseChatButton();
}
await panel.simulateDragIntoConference();
await p1.driver.waitUntil(
() => panel.isChatOpen(),
{ timeout: 3000, timeoutMsg: 'Chat did not open after drag' }
);
expect(await panel.isActive()).toBe(true);
});
it('dragging on pre-join screen does not open file sharing', async () => {
if (!fileSharingEnabled) {
return;
}
// Join with iFrame API, file-upload feature, and pre-join screen enabled — but do NOT click join
p1 = await joinJaasMuc(
{
name: 'p1',
iFrameApi: true,
token: t({ room: ctx.roomName, features: { 'file-upload': 'true' } })
},
{
configOverwrite: { prejoinConfig: { enabled: true } },
skipPrejoinButtonClick: true,
skipWaitToJoin: true
}
);
// Wait for pre-join screen to appear
await p1.getPreJoinScreen().waitForLoading();
// Simulate drag while on pre-join screen
const panel = p1.getFileSharingPanel();
await panel.simulateDragIntoConference();
// Chat/file sharing should NOT have opened
expect(await panel.isChatOpen()).toBe(false);
});
});

View File

@@ -0,0 +1,113 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { expectations } from '../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
import { TOKEN_AUTH_FAILED_TEST_ID, TOKEN_AUTH_FAILED_TITLE_TEST_ID } from '../../pageobjects/Notifications';
setTestProperties(__filename, {
useJaas: true
});
describe('XMPP login and MUC join', () => {
it('with a valid token (wildcard room)', async () => {
console.log('Joining a MUC with a valid token (wildcard room)');
const p = await joinJaasMuc({ token: t({ room: '*' }) });
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(false);
});
it('with a valid token (specific room)', async () => {
console.log('Joining a MUC with a valid token (specific room)');
const p = await joinJaasMuc({ token: t({ room: ctx.roomName }) });
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(false);
});
it('with a token with bad signature', async () => {
console.log('Joining a MUC with a token with bad signature');
const token = t({ room: ctx.roomName });
token.jwt = token.jwt + 'badSignature';
const p = await joinJaasMuc({ token });
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TEST_ID)
|| await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TITLE_TEST_ID);
expect(errorText).toContain('not allowed to join');
});
it('with an expired token', async () => {
console.log('Joining a MUC with an expired token');
const p = await joinJaasMuc({ token: t({ exp: '-1m' }) });
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TITLE_TEST_ID);
expect(errorText).toContain('Token is expired');
});
it('with a token using the wrong key ID', async () => {
console.log('Joining a MUC with a token using the wrong key ID');
const p = await joinJaasMuc({ token: t({ keyId: 'invalid-key-id' }) });
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TEST_ID);
expect(errorText).toContain('not allowed to join');
});
it('with a token for a different room', async () => {
console.log('Joining a MUC with a token for a different room');
const p = await joinJaasMuc({ token: t({ room: ctx.roomName + 'different' }) });
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TEST_ID);
expect(errorText).toContain('not allowed to join');
});
it('with a moderator token', async () => {
console.log('Joining a MUC with a moderator token');
const p = await joinJaasMuc({ token: t({ moderator: true }) });
expect(await p.isInMuc()).toBe(true);
expect(await p.isModerator()).toBe(true);
});
// This is dependent on jaas account configuration. All tests under jaas/ expect that "unauthenticated access" is
// disabled.
it('without a token', async () => {
console.log('Joining a MUC without a token');
const p = await joinJaasMuc();
if (expectations.jaas.unauthenticatedJoins) {
expect(Boolean(await p.isInMuc())).toBe(true);
} else {
expect(Boolean(await p.isInMuc())).toBe(false);
const errorText = await p.getNotifications().getNotificationText(TOKEN_AUTH_FAILED_TEST_ID);
expect(errorText).toContain('not allowed to join');
}
});
it('without sending a conference-request', async () => {
console.log('Joining a MUC without sending a conference-request');
const p = await joinJaasMuc({
token: t({ room: `${ctx.roomName}-no-cf` })
}, {
configOverwrite: {
disableFocus: true // this effectively disables the sending of a conference-request
}
});
expect(Boolean(await p.isInMuc())).toBe(false);
});
});

View File

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

View File

@@ -0,0 +1,37 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
useJaas: true
});
// JaaS deployments set deeplinking.[android|ios].appScheme to 'com.8x8.meet',
// which makes getDeepLinkingPage() short-circuit on mobile browsers (see
// react/features/deep-linking/functions.web.ts). This test verifies that
// behavior by emulating an iPhone and asserting the meeting is reached.
describe('JaaS mobile browser deep linking', () => {
before(async () => {
// Make Chrome look like an iPhone so Platform.OS resolves to 'ios' and
// isMobileBrowser() returns true. emulate() persists for the session.
await multiRemoteBrowser.getInstance('p1').emulate('device', 'iPhone 15');
});
it('does not show the deep linking page', async () => {
// joinJaasMuc waits for the prejoin screen with a 3s timeout. If the
// deep linking mobile page had rendered instead, that lookup would
// throw, failing the test. Reaching the assertion below means the
// prejoin was found.
const p = await joinJaasMuc({
token: t({ room: '*' })
}, {
skipWaitToJoin: true
});
// Belt-and-suspenders: the deep linking mobile page renders the
// 'launchMeetingLabel' string; assert it isn't on the page.
const deepLinkLabel = p.driver.$(
'//*[contains(text(),"How do you want to join this meeting?")]');
expect(await deepLinkLabel.isExisting()).toBe(false);
});
});

View File

@@ -0,0 +1,51 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
import { IToken } from '../../helpers/token';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2' ]
});
const passcode = '1234';
describe('Setting passcode through settings provisioning', () => {
it('With a valid passcode', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
passcode: passcode,
visitorsEnabled: true
};
// We want to keep the room from getting destroyed, because the visitors queue has a timeout and causes
// problems. We could use different rooms instead, but the webhooksProxy is only configured for the default room.
await joinWithPassword('p1', t({ room: ctx.roomName }));
await joinWithPassword('p2', t({ room: ctx.roomName, moderator: true }));
await joinWithPassword('p2', t({ room: ctx.roomName, visitor: true }));
});
});
/**
* Join a password-protected room. Assert that a password is required, that a wrong password does not work, and that
* the correct password does work.
*/
async function joinWithPassword(instanceId: string, token: IToken) {
// @ts-ignore
const p = await joinJaasMuc({ name: instanceId, token }, { roomName: ctx.roomName });
await p.waitForMucJoinedOrError();
expect(await p.isInMuc()).toBe(false);
expect(await p.getPasswordDialog().isOpen()).toBe(true);
await p.getPasswordDialog().submitPassword('wrong password');
await p.waitForMucJoinedOrError();
expect(await p.isInMuc()).toBe(false);
expect(await p.getPasswordDialog().isOpen()).toBe(true);
await p.getPasswordDialog().submitPassword(passcode);
await p.waitToJoinMUC();
expect(await p.isInMuc()).toBe(true);
expect(await p.getPasswordDialog().isOpen()).toBe(false);
}

View File

@@ -0,0 +1,25 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true
});
// This test is separate from passcode.spec.ts, because it needs to use a different room name, and webhooksProxy is only
// setup for the default room name.
describe('Setting invalid passcode through settings provisioning', () => {
it('With an invalid passcode', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
passcode: 'passcode-must-be-digits-only'
};
const p = await joinJaasMuc({ token: t({ room: ctx.roomName }) }, { roomName: ctx.roomName });
// The settings provisioning contains an invalid passcode, the expected result is that the room is not
// configured to require a passcode.
await p.waitToJoinMUC();
expect(await p.isInMuc()).toBe(true);
expect(await p.getPasswordDialog().isOpen()).toBe(false);
});
});

View File

@@ -0,0 +1,176 @@
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import WebhookProxy from '../../helpers/WebhookProxy';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2' ]
});
/**
* Tests the basic webhooks fired for participants joining, leaving, and creating/destroying a conference:
* PARTICIPANT_JOINED, PARTICIPANT_LEFT, ROOM_CREATED, ROOM_DESTROYED, ROLE_CHANGED, USAGE.
*/
describe('Create/destroy/join/leave webhooks', () => {
let conferenceJid: string = '';
let p1: Participant, p2: Participant;
let p1EpId: string, p2EpId: string;
let webhooksProxy: WebhookProxy;
let room: string;
async function checkParticipantJoinedHook(p: Participant) {
const event: {
data: {
conference: string;
isBreakout: boolean;
moderator: boolean;
name: string;
participantId: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_JOINED');
expect(event.eventType).toBe('PARTICIPANT_JOINED');
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.isBreakout).toBe(false);
expect(event.data.moderator).toBe(p.getToken()?.options?.moderator);
expect(event.data.name).toBe(await p.getLocalDisplayName());
expect(event.data.participantId).toBe(await p.getEndpointId());
expect(event.data.name).toBe(p.name);
}
async function checkParticipantLeftHook(p: Participant, reason: string) {
const event: {
customerId: string;
data: {
conference: string;
disconnectReason: string;
group: string;
id: string;
isBreakout: boolean;
name: string;
participantId: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_LEFT');
expect(event.eventType).toBe('PARTICIPANT_LEFT');
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.disconnectReason).toBe(reason);
expect(event.data.isBreakout).toBe(false);
expect(event.data.participantId).toBe(await p.getEndpointId());
expect(event.data.name).toBe(p.name);
const jwtPayload = p.getToken()?.payload;
expect(event.data.id).toBe(jwtPayload?.context?.user?.id);
expect(event.data.group).toBe(jwtPayload?.context?.group);
expect(event.customerId).toBe(testsConfig.jaas.customerId);
}
it('setup', async () => {
room = ctx.roomName;
conferenceJid = `${room}@conference.${testsConfig.jaas.tenant}.${new URL(process.env.BASE_URL || '').hostname}`;
webhooksProxy = ctx.webhooksProxy;
p1 = await joinJaasMuc({ name: 'p1', iFrameApi: true, token: t({ room, moderator: true }) });
p1EpId = await p1.getEndpointId();
expect(await p1.isModerator()).toBe(true);
await checkParticipantJoinedHook(p1);
await p1.switchToMainFrame();
p2 = await joinJaasMuc({ name: 'p2', token: t({ room }) });
p2EpId = await p2.getEndpointId();
expect(await p2.isModerator()).toBe(false);
await checkParticipantJoinedHook(p2);
});
it('USAGE webhook', async () => {
const event: {
data: [
{ participantId: string; }
];
eventType: string;
} = await webhooksProxy.waitForEvent('USAGE');
expect(event.eventType).toBe('USAGE');
expect(event.data.some(d => d.participantId === p1EpId));
expect(event.data.some(d => d.participantId === p2EpId));
});
it('ROOM_CREATED webhook', async () => {
const event: {
data: {
conference: string;
isBreakout: boolean;
};
eventType: string;
} = await webhooksProxy.waitForEvent('ROOM_CREATED');
expect(event.eventType).toBe('ROOM_CREATED');
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.isBreakout).toBe(false);
});
it('ROLE_CHANGED webhook', async () => {
await p1.getIframeAPI().executeCommand('grantModerator', p2EpId);
const event: {
data: {
grantedBy: {
participantId: string;
};
grantedTo: {
participantId: string;
};
role: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('ROLE_CHANGED');
expect(event.eventType).toBe('ROLE_CHANGED');
expect(event.data.role).toBe('moderator');
expect(event.data.grantedBy.participantId).toBe(p1EpId);
expect(event.data.grantedTo.participantId).toBe(p2EpId);
});
it('kick participant', async () => {
webhooksProxy.clearCache();
await p1.getIframeAPI().executeCommand('kickParticipant', p2EpId);
await checkParticipantLeftHook(p2, 'kicked');
});
it('join after kick', async () => {
webhooksProxy.clearCache();
// join again
p2 = await joinJaasMuc({ name: 'p2', token: t({ room }) });
p2EpId = await p2.getEndpointId();
await checkParticipantJoinedHook(p2);
});
it('hangup', async () => {
await p2.hangup();
await checkParticipantLeftHook(p2, 'left');
});
it('dispose conference', async () => {
await p1.getIframeAPI().executeCommand('hangup');
await checkParticipantLeftHook(p1, 'left');
const event: {
data: {
conference: string;
isBreakout: boolean;
};
eventType: string;
} = await webhooksProxy.waitForEvent('ROOM_DESTROYED');
expect(event.eventType).toBe('ROOM_DESTROYED');
expect(event.data.conference).toBe(conferenceJid);
expect(event.data.isBreakout).toBe(false);
});
});

View File

@@ -0,0 +1,224 @@
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import WebhookProxy from '../../helpers/WebhookProxy';
import { expectations } from '../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
retry: true,
useJaas: true
});
/**
* Tests the recording and live-streaming functionality of JaaS (including relevant webhooks) exercising the iFrame API
* commands and functions.
* TODO: also assert "this meeting is being recorded" notifications are show/played?
*/
describe('Recording and live-streaming', () => {
const tenant = testsConfig.jaas.tenant;
const customerId = tenant?.replace('vpaas-magic-cookie-', '');
let recordingEnabled: boolean;
let liveStreamingEnabled: boolean;
let p: Participant;
let webhooksProxy: WebhookProxy;
it('setup', async () => {
webhooksProxy = ctx.webhooksProxy;
p = await joinJaasMuc({ iFrameApi: true, token: t({ moderator: true }) }, { roomName: ctx.roomName });
recordingEnabled = Boolean(await p.execute(() => config.recordingService?.enabled));
expect(recordingEnabled).toBe(expectations.jaas.recordingEnabled);
liveStreamingEnabled = Boolean(await p.execute(() => config.liveStreaming?.enabled));
expect(liveStreamingEnabled).toBe(expectations.jaas.liveStreamingEnabled);
if (liveStreamingEnabled && !process.env.YTUBE_TEST_STREAM_KEY) {
liveStreamingEnabled = false;
console.log('Skipping live-streaming tests because YTUBE_TEST_STREAM_KEY is not set.');
}
await p.switchToMainFrame();
});
/**
* Starts recording and asserts that the expected iFrame and JaaS events are received.
* @param command whether to use the "command" or the "function" iFrame API.
*/
async function startRecording(command: boolean) {
await p.getIframeAPI().addEventListener('recordingStatusChanged');
await p.getIframeAPI().addEventListener('recordingLinkAvailable');
if (command) {
await p.getIframeAPI().executeCommand('startRecording', {
mode: 'file'
});
} else {
await p.getIframeAPI().startRecording({
mode: 'file'
});
}
const jaasEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('RECORDING_STARTED');
expect('RECORDING_STARTED').toBe(jaasEvent.eventType);
expect(jaasEvent.customerId).toBe(customerId);
webhooksProxy.clearCache();
const iFrameEvent = await p.driver.waitUntil(() =>
p.getIframeAPI().getEventResult('recordingStatusChanged'), {
timeout: 5000,
timeoutMsg: 'recordingStatusChanged event not received'
});
expect(iFrameEvent.mode).toBe('file');
expect(iFrameEvent.on).toBe(true);
const linkEvent = await p.driver.waitUntil(() =>
p.getIframeAPI().getEventResult('recordingLinkAvailable'), {
timeout: 5000,
timeoutMsg: 'recordingLinkAvailable event not received'
});
expect(linkEvent.link).toStartWith('https://', 'recording link');
expect(linkEvent.link).toContain(tenant);
expect(linkEvent.ttl).toBeGreaterThan(0);
}
/**
* Stops recording and asserts that the expected iFrame and JaaS events are received.
* @param command whether to use the "command" or the "function" iFrame API.
*/
async function stopRecording(command: boolean) {
if (command) {
await p.getIframeAPI().executeCommand('stopRecording', 'file');
} else {
await p.getIframeAPI().stopRecording('file');
}
const jaasEndedEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('RECORDING_ENDED');
expect('RECORDING_ENDED').toBe(jaasEndedEvent.eventType);
expect(jaasEndedEvent.customerId).toBe(customerId);
const jaasUploadedEvent: {
customerId: string;
data: {
initiatorId: string;
participants: Array<string>;
};
eventType: string;
} = await webhooksProxy.waitForEvent('RECORDING_UPLOADED');
const jwtPayload = p.getToken()?.payload;
expect(jaasUploadedEvent.data.initiatorId).toBe(jwtPayload?.context?.user?.id);
expect(jaasUploadedEvent.data.participants.some(
// @ts-ignore
e => e.id === jwtPayload?.context?.user?.id)).toBe(true);
webhooksProxy.clearCache();
const iFrameEvent = (await p.getIframeAPI().getEventResult('recordingStatusChanged'));
expect(iFrameEvent.mode).toBe('file');
expect(iFrameEvent.on).toBe(false);
await p.getIframeAPI().clearEventResults('recordingStatusChanged');
}
it('start/stop recording using the iFrame command', async () => {
if (!recordingEnabled) {
return;
}
await startRecording(true);
await stopRecording(true);
// to avoid rate limits
await p.driver.pause(30000);
});
it('start/stop recording using the iFrame function', async () => {
if (!recordingEnabled) {
return;
}
await startRecording(false);
await stopRecording(false);
// to avoid rate limits
await p.driver.pause(30000);
});
it('start/stop live-streaming using the iFrame command', async () => {
if (!liveStreamingEnabled) {
return;
}
await p.getIframeAPI().addEventListener('recordingStatusChanged');
await p.getIframeAPI().executeCommand('startRecording', {
youtubeBroadcastID: process.env.YTUBE_TEST_BROADCAST_ID,
mode: 'stream',
youtubeStreamKey: process.env.YTUBE_TEST_STREAM_KEY
});
const jaasEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('LIVE_STREAM_STARTED');
expect('LIVE_STREAM_STARTED').toBe(jaasEvent.eventType);
expect(jaasEvent.customerId).toBe(customerId);
const iFrameEvent = await p.driver.waitUntil(async () => {
const e = await p.getIframeAPI().getEventResult('recordingStatusChanged');
return e && e.mode === 'stream' && e.on === true ? e : false;
}, {
timeout: 5000,
timeoutMsg: 'recordingStatusChanged event with mode=stream, on=true not received'
});
expect(iFrameEvent.mode).toBe('stream');
expect(iFrameEvent.on).toBe(true);
if (process.env.YTUBE_TEST_BROADCAST_ID) {
const liveStreamUrl = await p.getIframeAPI().getLivestreamUrl();
expect(liveStreamUrl.livestreamUrl).toBeDefined();
}
await p.getIframeAPI().executeCommand('stopRecording', 'stream');
const jaasEndedEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('LIVE_STREAM_ENDED');
expect(jaasEndedEvent.eventType).toBe('LIVE_STREAM_ENDED');
expect(jaasEndedEvent.customerId).toBe(customerId);
const iFrameEndedEvent = await p.driver.waitUntil(async () => {
const e = await p.getIframeAPI().getEventResult('recordingStatusChanged');
return e && e.mode === 'stream' && e.on === false ? e : false;
}, {
timeout: 5000,
timeoutMsg: 'recordingStatusChanged event with mode=stream, on=false not received'
});
expect(iFrameEndedEvent.mode).toBe('stream');
expect(iFrameEndedEvent.on).toBe(false);
});
});

View File

@@ -0,0 +1,138 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { expectations } from '../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
/**
* Joins (or rejoins) p1 as a moderator granting only the given JWT feature claims, with no
* config.js overrides anywhere in this file — see recordButtonProps() / isLiveStreamingButtonVisible()
* in react/features/recording: on a JaaS (vpaas) tenant there is no config-only fallback once the
* JWT lacks a feature claim, unlike the non-JaaS tenant, so toolbar visibility here is driven purely
* by these feature claims against the environment's ambient (always-on, per expectations.jaas.*)
* config. The config-driven side of this same dialog (no custom JWT, varying config.js instead) is
* covered separately by tests/specs/misc/recordingDialogConfig.spec.ts.
*
* A fresh join is required (rather than ensureOneParticipant, which no-ops if already in the
* conference) because each test in this file exercises a different set of JWT feature claims.
*/
async function joinWithFeatures(features: Record<string, boolean>): Promise<void> {
await joinJaasMuc({
name: 'p1',
token: t({
moderator: true,
features
})
});
}
/**
* Mirrors LocalRecordingManager.isSupported() plus the embedding guard that
* supportsLocalRecording() adds — the same probe tests/specs/misc/recordingDialogConfig.spec.ts and
* recordingButtonVisibility.spec.ts use, because these browser capability gates are frequently
* unmet in the automated test browser regardless of any JWT feature claim.
*/
async function isLocalRecordingSupportedByBrowser(): Promise<boolean> {
return ctx.p1.execute(() => {
const browser = JitsiMeetJS.util.browser;
const PREFERRED_MEDIA_TYPE = 'video/webm;codecs=vp8,opus';
const isSupported = browser.isChromiumBased()
&& !browser.isElectron()
&& !browser.isReactNative()
&& !browser.isMobileDevice()
// @ts-ignore
&& Boolean(navigator.mediaDevices.setCaptureHandleConfig)
// @ts-ignore
&& typeof window.showSaveFilePicker !== 'undefined'
&& MediaRecorder.isTypeSupported(PREFERRED_MEDIA_TYPE);
let embeddedOk = true;
try {
if (window.self !== window.top) {
embeddedOk = window.self.location.host === window.parent.location.host;
}
} catch (e) {
embeddedOk = false;
}
return isSupported && embeddedOk;
});
}
setTestProperties(__filename, {
description: 'Recording & Transcription dialog toolbar visibility across JWT feature claims',
usesBrowsers: [ 'p1' ],
useJaas: true
});
/*
* Full 2^3 matrix over the three independently grantable JWT feature claims: recording,
* transcription and livestreaming. No config.js override is used anywhere here — the environment's
* ambient config is assumed to always enable recordingService, transcription and liveStreaming (see
* expectations.jaas.*), so every combination below isolates the JWT-feature-gated branches of
* getRecordButtonProps() and isLiveStreamingButtonVisible() rather than config:
*
* - On a JaaS tenant, getRecordButtonProps() has no config-only fallback: the toolbar Record &
* Transcribe button's visibility reduces to localRecordingAvailable || recording || transcription
* (the JWT claims), since the ambient config enables both services.
* - The Live Streaming toolbar button is fully orthogonal and purely JWT-gated: only the
* livestreaming claim matters (see LiveStream/AbstractLiveStreamButton.ts).
*
* localRecordingAvailable also depends on browser capabilities (MediaRecorder/File System Access
* support) that are frequently unmet in the automated test browser — probed once up front so the
* per-combination expectations stay accurate regardless of what the test browser actually supports.
*/
describe('Recording dialog JWT feature matrix — recording × transcription × live streaming', () => {
// A const-bound holder object, rather than a reassigned `let`, so the it() closures created
// inside the loop below don't trip @typescript-eslint/no-loop-func — only its property (set
// once, in 'setup', before any of the loop's tests run) actually changes.
const localRecordingSupport = { byBrowser: false };
it('setup', async () => {
if (!expectations.jaas.recordingEnabled
|| !expectations.jaas.transcriptionEnabled
|| !expectations.jaas.liveStreamingEnabled) {
ctx.skipSuiteTests = 'Recording, transcription and live streaming must all be enabled '
+ 'in this environment for the JWT feature claims to be the only variable';
return;
}
await joinWithFeatures({
recording: true,
transcription: true,
livestreaming: true
});
localRecordingSupport.byBrowser = await isLocalRecordingSupportedByBrowser();
});
const BOOLEANS = [ true, false ];
for (const recordingFeature of BOOLEANS) {
for (const transcriptionFeature of BOOLEANS) {
for (const livestreamingFeature of BOOLEANS) {
const title = `recording=${recordingFeature} `
+ `transcription=${transcriptionFeature} `
+ `livestreaming=${livestreamingFeature}`;
it(title, async () => {
await joinWithFeatures({
recording: recordingFeature,
transcription: transcriptionFeature,
livestreaming: livestreamingFeature
});
const p1 = ctx.p1;
expect(await p1.getToolbar().hasLiveStreamingButton()).toBe(livestreamingFeature);
const dialogCanOpen = recordingFeature || transcriptionFeature || localRecordingSupport.byBrowser;
expect(await p1.getToolbar().hasRecordingButton()).toBe(dialogCanOpen);
});
}
}
}
});

View File

@@ -0,0 +1,617 @@
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { expectations } from '../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
useJaas: true
});
// Counter used to give each test its own room — see joinAsModerator().
let roomCounter = 0;
/**
* Builds a fresh, per-test room name out of ctx.roomName (see joinAsModerator()).
*
* ctx.roomName already has the environment's ROOM_NAME_SUFFIX (e.g. "_alpha-canary") appended as
* its trailing component (see generateRoomName() in helpers/utils.ts), and some environments rely
* on that suffix being the tail of the room name (e.g. to route/map the conference correctly).
* Simply appending "-${roomCounter}" to ctx.roomName would push the suffix out of that position,
* so the counter is inserted right before it instead.
*/
function uniqueRoomName(): string {
const suffix = testsConfig.roomName.suffix;
if (suffix && ctx.roomName.endsWith(`_${suffix}`)) {
return `${ctx.roomName.slice(0, -`_${suffix}`.length)}-${roomCounter}_${suffix}`;
}
return `${ctx.roomName}-${roomCounter}`;
}
/**
* Joins a JaaS meeting as a moderator using the iFrame API wrapper.
*
* Each call joins a unique room. All tests in this spec otherwise share ctx.roomName, and joinMuc
* rejoins it each test, so prosody's per-room jibri start/stop throttle (mod_filter_iq_jibri,
* max_number_room_attempts_per_minute, default 3 — both start and stop count) accumulates across
* the suite and trips the "started a recording too quickly" policy error. A fresh room per test
* resets that throttle, keeping each test's ≤2 start/stop attempts well under the limit. The
* moderator token is room-agnostic (room defaults to '*'), so it is valid for any room.
*/
async function joinAsModerator(): Promise<Participant> {
roomCounter += 1;
const p = await joinJaasMuc(
{ iFrameApi: true, token: t({ moderator: true }) },
{ roomName: uniqueRoomName() });
// joinJaasMuc leaves the driver focused inside the Jitsi iframe, but the iFrame API
// (window.jitsiAPI) lives on the wrapper page. Switch to the main frame so executeCommand()
// works, matching the setup in recording.spec.ts / transcriptions.spec.ts.
await p.switchToMainFrame();
return p;
}
/**
* Minimum wall-clock gap enforced between successive Jibri start/stop commands sent by this
* spec, in ms.
*
* Prosody's mod_filter_iq_jibri throttles jibri start/stop IQs per source IP
* (max_number_ip_attempts_per_minute, default 9/min), separately from the per-room throttle
* joinAsModerator()'s fresh room already dodges — a fresh room resets the room-scoped throttle
* only, not this one. This suite alone sends far more than 9 start/stop IQs across its tests, so
* without pacing it trips the per-IP throttle (observed as "policy-violation"/wait on the start
* or stop IQ, surfacing as a session that never actually starts or stops). 8s keeps this file's
* rate under the throttle's refill rate (60s / 9 ≈ 6.7s), with headroom for other jaas specs
* sharing the same CI egress IP.
*/
const MIN_JIBRI_COMMAND_GAP_MS = 8_000;
let lastJibriCommandTime = 0;
/**
* Waits out whatever is left of MIN_JIBRI_COMMAND_GAP_MS since the last call, then marks now as
* the new last call time. Await this immediately before every jibri start/stop command.
*/
async function paceJibriCommand(): Promise<void> {
const wait = MIN_JIBRI_COMMAND_GAP_MS - (Date.now() - lastJibriCommandTime);
if (wait > 0) {
await new Promise(resolve => setTimeout(resolve, wait));
}
lastJibriCommandTime = Date.now();
}
/**
* Sends the startRecording command and waits until jicofo actually accepts it (a live FILE
* session shows up in the recording state), retrying on silent rejection.
*
* A start IQ that reaches jicofo right after joining can be rejected with policy-violation
* (type=wait) while the conference is still being set up, and the client surfaces nothing —
* the recording just never starts. The error type says "retry later", so do that. Waiting for
* the JVB session before starting instead is NOT an option: jicofo does not allocate a session
* for a lone participant at all, it does so only when a second endpoint (e.g. jibri) joins.
*
* Expects the driver on the main frame; leaves it there.
*/
async function startFileRecording(p: Participant, options: { mode: string; transcription?: boolean; }) {
for (let attempt = 1; attempt <= 3; attempt++) {
await paceJibriCommand();
await p.getIframeAPI().executeCommand('startRecording', options);
await p.switchToIFrame();
const accepted = await p.driver.waitUntil(
() => p.execute(() => Boolean(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
APP.store.getState()['features/recording'].sessionDatas.some(
(s: any) => s.mode === 'file' && (s.status === 'pending' || s.status === 'on')))),
{ timeout: 5_000, timeoutMsg: '' }
).catch(() => false);
await p.switchToMainFrame();
if (accepted) {
return;
}
}
throw new Error('startRecording was not accepted by jicofo after 3 attempts');
}
/**
* Waits for the "dialog.recording" notification to appear inside the current iframe context.
* The notification fires whenever recording or transcription starts (or both).
*/
async function waitForRecordingNotification(p: Participant, timeoutMsg: string): Promise<void> {
await p.driver.$('[data-testid="dialog.recording"]').waitForExist({
timeout: 30_000,
timeoutMsg
});
}
/**
* Dismisses the current "dialog.recording" notification (if it is showing) and waits for it
* to disappear, so subsequent assertions start from a clean slate.
*/
async function dismissRecordingNotification(p: Participant): Promise<void> {
const dismissButton = p.driver.$('[data-testid="dialog.recording-dismiss"]');
if (await dismissButton.isExisting()) {
await dismissButton.moveTo();
await dismissButton.click();
await p.driver.$('[data-testid="dialog.recording"]')
.waitForExist({ reverse: true, timeout: 3_000 });
}
}
/**
* Waits until the recording feature state reports that a FILE-mode session is fully ON.
* A PENDING session is not enough: jicofo rejects a stop request for a session whose jibri
* is still starting (policy-violation/wait), so acting on a PENDING session races the backend.
*/
async function waitForRecordingRunning(p: Participant): Promise<void> {
await p.driver.waitUntil(
() => p.execute(() => Boolean(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
APP.store.getState()['features/recording'].sessionDatas.some(
(s: any) => s.mode === 'file' && s.status === 'on'))),
{ timeout: 30_000, timeoutMsg: 'Recording session did not become active' }
);
}
/**
* Waits until the transcription feature state reports that transcription is active.
*/
async function waitForTranscriptionRunning(p: Participant): Promise<void> {
await p.driver.waitUntil(
() => p.execute(() => Boolean(APP.store.getState()['features/transcribing'].isTranscribing)),
{ timeout: 30_000, timeoutMsg: 'Transcription did not become active' }
);
}
/**
* Waits until the transcription feature state reports that transcription is no longer active.
*
* @param {Participant} p - The participant.
* @param {number} timeout - How long to wait, in ms.
*/
async function waitForTranscriptionStopped(p: Participant, timeout = 20_000): Promise<void> {
await p.driver.waitUntil(
() => p.execute(() => !APP.store.getState()['features/transcribing'].isTranscribing),
{ timeout, timeoutMsg: 'Transcription did not stop' }
);
}
/**
* Waits until the recording feature state reports that no FILE session is active.
*
* @param {Participant} p - The participant.
* @param {number} timeout - How long to wait, in ms.
*/
async function waitForRecordingStopped(p: Participant, timeout = 20_000): Promise<void> {
await p.driver.waitUntil(
() => p.execute(() => Boolean(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
APP.store.getState()['features/recording'].sessionDatas.every(
(s: any) => s.mode !== 'file' || s.status === 'off'))),
{ timeout, timeoutMsg: 'Recording session did not stop' }
);
}
/**
* Best-effort wait, used in cleanup, for a stopRecording command to actually take effect before the
* participant leaves. Never fails the test — a timeout here just means the stop was slow to reflect,
* which must not turn a passing test red during teardown.
*
* @param {Participant} p - The participant.
* @param {boolean} transcription - Whether transcription was also stopped (stopRecording(..., true)).
*/
async function waitForRecordingStopExecuted(p: Participant, transcription = false): Promise<void> {
// Short, bounded wait — this is best-effort teardown, not an assertion, so don't burn the full
// 20s assertion timeout here.
const cleanupTimeout = 5_000;
await p.switchToIFrame();
try {
await waitForRecordingStopped(p, cleanupTimeout);
if (transcription) {
await waitForTranscriptionStopped(p, cleanupTimeout);
}
} catch (e) {
// Best-effort cleanup — ignore timeouts so teardown never fails the test.
}
}
// ─── Nudge notification tests ────────────────────────────────────────────────
describe('Recording & transcription nudge notifications', () => {
it('nudge shows "Start transcribing" action when only recording starts', async () => {
if (!expectations.jaas.recordingEnabled || !expectations.jaas.transcriptionEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, { mode: 'file' });
await p.switchToIFrame();
await waitForRecordingNotification(p, 'Recording-started notification did not appear');
const nudgeButton = p.driver.$('[data-testid="dialog.startTranscribing"]');
// A "recording pending" notification appears (same data-testid) before the "recording
// started" notification that actually carries the nudge, so wait for the nudge to show up
// rather than checking immediately. The iFrame startRecording path sets
// isTranscribingEnabled:false, so transcription never auto-starts here and the
// "Start transcribing" nudge always applies once recording is running.
await nudgeButton.waitForExist({
timeout: 30_000,
timeoutMsg: '"Start transcribing" nudge did not appear'
});
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file');
await waitForRecordingStopExecuted(p);
}
});
it('nudge shows "Start recording" action when only transcription starts', async () => {
if (!expectations.jaas.transcriptionEnabled || !expectations.jaas.recordingEnabled) {
return;
}
const p = await joinAsModerator();
try {
await paceJibriCommand();
await p.getIframeAPI().executeCommand('startRecording', { transcription: true });
await p.switchToIFrame();
await waitForRecordingNotification(p, 'Transcription-started notification did not appear');
const nudgeButton = p.driver.$('[data-testid="dialog.startRecording"]');
expect(await nudgeButton.isExisting()).toBe(true);
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file', true);
await waitForRecordingStopExecuted(p, true);
}
});
it('no spurious "recording started" notification when stopping transcription while recording runs', async () => {
if (!expectations.jaas.recordingEnabled || !expectations.jaas.transcriptionEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, {
mode: 'file',
transcription: true
});
await p.switchToIFrame();
await waitForRecordingNotification(p, 'Recording+transcription start notification did not appear');
await dismissRecordingNotification(p);
await p.switchToMainFrame();
// Stop only transcription while recording keeps running.
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file', true);
await p.switchToIFrame();
// Give the UI time to settle — a spurious notification would appear within ~3 s.
await p.driver.pause(4_000);
// The description keys used on a new "recording started" notification —
// neither must appear after a transcription-only stop.
expect(await p.driver.$('[data-testid="recording.on"]').isExisting()).toBe(false);
expect(await p.driver.$('[data-testid="recording.onWithTranscription"]').isExisting()).toBe(false);
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file');
await waitForRecordingStopExecuted(p);
}
});
});
// ─── Manage-mode (session active) dialog tests ───────────────────────────────
describe('Recording & transcription dialog — manage mode', () => {
it('recording section shows Stop when recording is running', async () => {
if (!expectations.jaas.recordingEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, { mode: 'file' });
await p.switchToIFrame();
await waitForRecordingRunning(p);
await p.getToolbar().clickRecordingButton();
const dialog = p.getRecordingTranscriptionDialog();
await dialog.waitForDisplay();
expect(await dialog.hasStopRecordingButton()).toBe(true);
expect(await dialog.hasStartTranscriptionButton()).toBe(true);
// Mixed state — the footer offers both bulk actions.
expect(await dialog.hasStopBothButton()).toBe(true);
expect(await dialog.hasStartBothButton()).toBe(true);
await dialog.cancel();
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file');
await waitForRecordingStopExecuted(p);
}
});
it('transcription section shows Stop when transcription is running', async () => {
if (!expectations.jaas.transcriptionEnabled) {
return;
}
const p = await joinAsModerator();
try {
await paceJibriCommand();
await p.getIframeAPI().executeCommand('startRecording', { transcription: true });
await p.switchToIFrame();
await waitForTranscriptionRunning(p);
await p.getToolbar().clickRecordingButton();
const dialog = p.getRecordingTranscriptionDialog();
await dialog.waitForDisplay();
expect(await dialog.hasStopTranscriptionButton()).toBe(true);
expect(await dialog.hasStartRecordingButton()).toBe(true);
// Mixed state — the footer offers both bulk actions.
expect(await dialog.hasStopBothButton()).toBe(true);
expect(await dialog.hasStartBothButton()).toBe(true);
await dialog.cancel();
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file', true);
await waitForRecordingStopExecuted(p, true);
}
});
it('both sections show Stop and footer shows only "Stop both" when both run', async () => {
if (!expectations.jaas.recordingEnabled || !expectations.jaas.transcriptionEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, {
mode: 'file',
transcription: true
});
await p.switchToIFrame();
await waitForRecordingRunning(p);
await waitForTranscriptionRunning(p);
await p.getToolbar().clickRecordingButton();
const dialog = p.getRecordingTranscriptionDialog();
await dialog.waitForDisplay();
expect(await dialog.hasStopRecordingButton()).toBe(true);
expect(await dialog.hasStopTranscriptionButton()).toBe(true);
expect(await dialog.hasStopBothButton()).toBe(true);
expect(await dialog.hasStartBothButton()).toBe(false);
await dialog.cancel();
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file');
await waitForRecordingStopExecuted(p);
}
});
it('"Recorder follows me" is locked while recording runs', async () => {
if (!expectations.jaas.recordingEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, { mode: 'file' });
await p.switchToIFrame();
await waitForRecordingRunning(p);
await p.getToolbar().clickRecordingButton();
const dialog = p.getRecordingTranscriptionDialog();
await dialog.waitForDisplay();
await dialog.toggleRecordingOptions();
// The switch only shows for moderators with a cloud service selected.
if (await dialog.hasFollowMeRecorderSwitch()) {
// The recorder cannot pick up the change mid-session.
expect(await dialog.isFollowMeRecorderEnabled()).toBe(false);
}
await dialog.cancel();
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file');
await waitForRecordingStopExecuted(p);
}
});
it('can stop transcription independently while recording keeps running', async () => {
if (!expectations.jaas.recordingEnabled || !expectations.jaas.transcriptionEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, {
mode: 'file',
transcription: true
});
await p.switchToIFrame();
await waitForRecordingRunning(p);
await waitForTranscriptionRunning(p);
await p.getToolbar().clickRecordingButton();
const dialog = p.getRecordingTranscriptionDialog();
await dialog.waitForDisplay();
// Stop only transcription — the action applies immediately and closes the dialog.
await paceJibriCommand();
await dialog.stopTranscription();
// Transcription should stop.
await waitForTranscriptionStopped(p);
// Recording should still be running.
const recordingStillOn = await p.execute(() => Boolean(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
APP.store.getState()['features/recording'].sessionDatas.some(
(s: any) => s.mode === 'file' && s.status !== 'off')
));
expect(recordingStillOn).toBe(true);
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file');
await waitForRecordingStopExecuted(p);
}
});
it('can stop recording independently while transcription keeps running', async () => {
if (!expectations.jaas.recordingEnabled || !expectations.jaas.transcriptionEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, {
mode: 'file',
transcription: true
});
await p.switchToIFrame();
await waitForRecordingRunning(p);
await waitForTranscriptionRunning(p);
await p.getToolbar().clickRecordingButton();
const dialog = p.getRecordingTranscriptionDialog();
await dialog.waitForDisplay();
// Stop only recording — the action applies immediately and closes the dialog.
await paceJibriCommand();
await dialog.stopRecording();
// Recording should stop.
await waitForRecordingStopped(p);
// Transcription should still be running.
const transcriptionStillOn = await p.execute(
() => Boolean(APP.store.getState()['features/transcribing'].isTranscribing)
);
expect(transcriptionStillOn).toBe(true);
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file', true);
await waitForRecordingStopExecuted(p, true);
}
});
it('"Stop both" stops recording and transcription together', async () => {
if (!expectations.jaas.recordingEnabled || !expectations.jaas.transcriptionEnabled) {
return;
}
const p = await joinAsModerator();
try {
await startFileRecording(p, {
mode: 'file',
transcription: true
});
await p.switchToIFrame();
await waitForRecordingRunning(p);
await waitForTranscriptionRunning(p);
await p.getToolbar().clickRecordingButton();
const dialog = p.getRecordingTranscriptionDialog();
await dialog.waitForDisplay();
await paceJibriCommand();
await dialog.stopBoth();
await waitForRecordingStopped(p);
await waitForTranscriptionStopped(p);
} finally {
await p.switchToMainFrame();
await paceJibriCommand();
await p.getIframeAPI().executeCommand('stopRecording', 'file', true);
await waitForRecordingStopExecuted(p, true);
}
});
});
// ─── Button visibility ────────────────────────────────────────────────────────
describe('Recording button visibility', () => {
it('moderator button has aria-label "Record & Transcribe"', async () => {
if (!expectations.jaas.recordingEnabled) {
return;
}
const p = await joinJaasMuc({ token: t({ moderator: true }) });
expect(await p.getToolbar().hasRecordingButton()).toBe(true);
// This participant joined without the iFrame API, so hang up via the app, not window.jitsiAPI.
await p.hangup();
});
});

View File

@@ -0,0 +1,251 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import type WebhookProxy from '../../helpers/WebhookProxy';
import { expectations } from '../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2' ]
});
const asyncTranscriptionValues = [
...expectations.jaas.transcription.jigasiTranscription ? [ false ] : [],
...expectations.jaas.transcription.asyncTranscription ? [ true ] : []
];
for (const asyncTranscriptions of asyncTranscriptionValues) {
describe(`Transcription (async=${asyncTranscriptions})`, () => {
let p1: Participant, p2: Participant;
let webhooksProxy: WebhookProxy;
async function clearTranscriptionStatusChange() {
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p2.getIframeAPI().clearEventResults('transcribingStatusChanged');
}
/**
* Wait until a transcribingStatusChanged iFrame event is received for both p1 and p2, with a specific value
* for the "on" field.
* Note that addEventListener for 'transcriptionChunkReceived' should have been called prior to this function.
*/
async function waitForTranscriptionStatusChange(expectedOn: boolean) {
for (const p of [ p1, p2 ]) {
const event = await p.driver.waitUntil(
() => p.getIframeAPI().getEventResult('transcribingStatusChanged'),
{
timeout: 10000,
timeoutMsg: `transcribingStatusChanged event not received on ${p.name}`
});
if (event.on !== expectedOn) {
throw new Error(`Expected transcribing to be ${expectedOn} for ${p.name}, got ${event.on}`);
}
if (!expectedOn && !asyncTranscriptions) {
// The "stopped" event is sometimes fired before the jigasi participant leaves. If we re-start
// transcription before jigasi has left, jicofo will reject the request.
await p.switchToIFrame();
await p.waitForParticipants(
1,
'Unexpected number of participants. Jigasi failed to leave?');
await p.switchToMainFrame();
}
}
}
it('setup', async () => {
const room = ctx.roomName;
webhooksProxy = ctx.webhooksProxy;
webhooksProxy.defaultMeetingSettings = { asyncTranscriptions };
p1 = await joinJaasMuc({
name: 'p1',
token: t({ room, moderator: true }),
iFrameApi: true
});
const transcriptionEnabled = await p1.execute(() => config.transcription?.enabled);
expect(transcriptionEnabled).toBe(expectations.jaas.transcriptionEnabled);
const roomMetadata = await p1.getRoomMetadata();
if (asyncTranscriptions) {
expect(roomMetadata.asyncTranscription).toBe(true);
} else {
expect(roomMetadata.asyncTranscription).toBeFalsy();
}
p2 = await joinJaasMuc({
name: 'p2',
token: t({ room }),
iFrameApi: true
}, {
configOverwrite: {
startWithAudioMuted: true
}
});
await Promise.all([
p1.switchToMainFrame(),
p2.switchToMainFrame(),
]);
expect(await p1.getIframeAPI().getEventResult('isModerator')).toBe(true);
expect(await p1.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
await p1.getIframeAPI().addEventListener('transcribingStatusChanged');
await p2.getIframeAPI().addEventListener('transcribingStatusChanged');
await p1.getIframeAPI().addEventListener('transcriptionChunkReceived');
await p2.getIframeAPI().addEventListener('transcriptionChunkReceived');
});
it('toggle subtitles', async () => {
await clearTranscriptionStatusChange();
await p1.getIframeAPI().executeCommand('toggleSubtitles');
await waitForTranscriptionStatusChange(true);
await checkReceivingChunks(p1, p2, webhooksProxy, asyncTranscriptions);
await clearTranscriptionStatusChange();
await p1.getIframeAPI().executeCommand('toggleSubtitles');
await waitForTranscriptionStatusChange(false);
});
it('set subtitles on and off', async () => {
// we need to clear results or the last one will be used, from the previous time subtitles were on
await clearTranscriptionStatusChange();
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
webhooksProxy.clearCache();
await p1.getIframeAPI().executeCommand('setSubtitles', true, true);
await waitForTranscriptionStatusChange(true);
await checkReceivingChunks(p1, p2, webhooksProxy, asyncTranscriptions);
await clearTranscriptionStatusChange();
await p1.getIframeAPI().executeCommand('setSubtitles', false);
await waitForTranscriptionStatusChange(false);
});
it('start/stop transcriptions via recording', async () => {
// we need to clear results or the last one will be used, from the previous time subtitles were on
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await clearTranscriptionStatusChange();
webhooksProxy.clearCache();
await p1.getIframeAPI().executeCommand('startRecording', { transcription: true });
await waitForTranscriptionStatusChange(true);
await checkReceivingChunks(p1, p2, webhooksProxy, asyncTranscriptions);
await clearTranscriptionStatusChange();
await p1.getIframeAPI().executeCommand('stopRecording', 'file', true);
await waitForTranscriptionStatusChange(false);
await p1.getIframeAPI().executeCommand('hangup');
await p2.getIframeAPI().executeCommand('hangup');
// sometimes events are not immediately received,
// let's wait for destroy event before waiting for those that depends on it
await webhooksProxy.waitForEvent('ROOM_DESTROYED');
if (!asyncTranscriptions || expectations.jaas.transcription.asyncTranscriptionWebhook) {
const event: {
data: {
preAuthenticatedLink: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_UPLOADED');
expect(event.eventType).toBe('TRANSCRIPTION_UPLOADED');
expect(event.data.preAuthenticatedLink).toBeDefined();
}
});
});
}
/**
*
* @param p1
* @param p2
* @param webhooksProxy
* @param asyncTranscription Whether async transciptions are used.
*/
async function checkReceivingChunks(p1: Participant, p2: Participant, webhooksProxy: WebhookProxy, asyncTranscription = false) {
const p1Promise = p1.driver.waitUntil(() => p1.getIframeAPI()
.getEventResult('transcriptionChunkReceived'), {
timeout: 60000,
timeoutMsg: 'transcriptionChunkReceived event not received on p1 side'
});
const p2Promise = p2.driver.waitUntil(() => p2.getIframeAPI()
.getEventResult('transcriptionChunkReceived'), {
timeout: 60000,
timeoutMsg: 'transcriptionChunkReceived event not received on p2 side'
});
const [ p1Event, p2Event ] = await Promise.all([ p1Promise, p2Promise ]);
const p1Id = await p1.getEndpointId();
const p1Transcript = p1Event.data.stable || p1Event.data.final;
const p2Transcript = p2Event.data.stable || p2Event.data.final;
expect(p2Transcript).toPartiallyMatch(p1Transcript, 'p2 transcript', 'p1 transcript');
expect(p2Event.data.language).toBe(p1Event.data.language);
expect(p2Event.data.messageID).toBe(p1Event.data.messageID);
expect(p1Event.data.participant.id).toBe(p1Id);
expect(p2Event.data.participant.id).toBe(p1Id);
if (!asyncTranscription) {
expect(p1Event.data.participant.name).toBe(p1.name);
expect(p2Event.data.participant.name).toBe(p1.name);
}
if (!asyncTranscription || expectations.jaas.transcription.asyncTranscriptionWebhook) {
// In async mode the backend re-IDs and re-segments chunks after post-processing, so live (iframe) and
// async (webhook) ID spaces don't overlap and transcript text won't correlate. Match by participant only
// and skip the content comparison.
const event: {
data: {
final: string;
language: string;
messageID: string;
participant: {
id: string;
name: string;
};
stable: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent(
'TRANSCRIPTION_CHUNK_RECEIVED',
asyncTranscription
? e => e.data.participant.id === p1Id
: e => e.data.messageID === p1Event.data.messageID
);
expect(event.eventType).toBe('TRANSCRIPTION_CHUNK_RECEIVED');
if (!asyncTranscription) {
const webhookTranscript = event.data.final;
expect(webhookTranscript).toPartiallyMatch(p1Transcript, 'webhook transcript', 'p1 transcript');
}
if (p1Event.data.language) {
expect(event.data.language).toBe(p1Event.data.language);
}
expect(event.data.participant.id).toBe(p1Id);
if (!asyncTranscription) {
expect(event.data.participant.name).toBe(p1.name);
}
}
}

View File

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

View File

@@ -0,0 +1,60 @@
import { setTestProperties } from '../../../helpers/TestProperties';
import { joinJaasMuc, generateJaasToken as t } from '../../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2', 'p3', 'p4' ]
});
/**
* This is a case which fails if jitsi-videobridge doesn't properly forward PLIs from visitors.
*/
describe('Visitor receiving video from a single remote participant', () => {
it('joining the meeting', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
visitorsEnabled: true,
visitorsLive: true,
};
// Force a connection via JVB.
const configOverwrite = {
p2p: {
enabled: false
}
};
const sender = await joinJaasMuc({
token: t({ room: ctx.roomName, displayName: 'Sender', moderator: true })
}, {
configOverwrite
});
const senderEndpointId = await sender.getEndpointId();
const testVisitor = async function(instanceId: 'p1' | 'p2' | 'p3' | 'p4') {
const visitor = await joinJaasMuc({
name: instanceId,
token: t({ room: ctx.roomName, displayName: 'Visitor', visitor: true })
}, {
configOverwrite
});
await visitor.waitForIceConnected();
const iceConnected = performance.now();
await visitor.driver.waitUntil(
() => visitor.isRemoteVideoReceivedAndDisplayed(senderEndpointId), {
timeout: 10_000,
timeoutMsg: `Visitor (${instanceId}) is not receiving video from the sender`
});
const duration = performance.now() - iceConnected;
console.log(`Video displayed after ${duration} ms after ICE connected (${instanceId})`);
};
await testVisitor('p2');
await testVisitor('p3');
await testVisitor('p4');
});
});

View File

@@ -0,0 +1,140 @@
import { Participant } from '../../../helpers/Participant';
import { setTestProperties } from '../../../helpers/TestProperties';
import { config as testsConfig } from '../../../helpers/TestsConfig';
import WebhookProxy from '../../../helpers/WebhookProxy';
import { joinJaasMuc, generateJaasToken as t } from '../../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
describe('Visitors triggered by visitor tokens', () => {
let webhooksProxy: WebhookProxy;
let room: string;
async function verifyJoinedWebhook(participant: Participant) {
const context = participant.getToken()?.payload.context;
const event: {
customerId: string;
data: {
avatar: string;
email: string;
group: string;
id: string;
name: string;
participantJid: string;
role: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_JOINED');
expect('PARTICIPANT_JOINED').toBe(event.eventType);
expect(event.data.avatar).toBe(context.user.avatar);
expect(event.data.email).toBe(context.user.email);
expect(event.data.id).toBe(context.user.id);
expect(event.data.group).toBe(context.group);
expect(event.data.name).toBe(context.user.name);
if (context.user.visitor) {
expect(event.data.participantJid.indexOf('meet.jitsi') != -1).toBe(true);
expect(event.data.role).toBe('visitor');
}
expect(event.customerId).toBe(testsConfig.jaas.customerId);
}
async function verifyLeftWebhook(participant: Participant) {
const context = participant.getToken()?.payload.context;
const eventLeft: {
customerId: string;
data: {
avatar: string;
email: string;
group: string;
id: string;
name: string;
participantJid: string;
role: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('PARTICIPANT_LEFT');
expect('PARTICIPANT_LEFT').toBe(eventLeft.eventType);
expect(eventLeft.data.avatar).toBe(context.user.avatar);
expect(eventLeft.data.email).toBe(context.user.email);
expect(eventLeft.data.id).toBe(context.user.id);
expect(eventLeft.data.group).toBe(context.group);
expect(eventLeft.data.name).toBe(context.user.name);
if (context.user.visitor) {
expect(eventLeft.data.participantJid.indexOf('meet.jitsi') != -1).toBe(true);
expect(eventLeft.data.role).toBe('visitor');
}
expect(eventLeft.customerId).toBe(testsConfig.jaas.customerId);
}
it('setup', async () => {
webhooksProxy = ctx.webhooksProxy;
webhooksProxy.defaultMeetingSettings = {
visitorsEnabled: true
};
room = ctx.roomName;
});
it('test visitor tokens', async () => {
webhooksProxy.clearCache();
const moderatorToken = t({ room, displayName: 'Mo de Rator', moderator: true });
const moderator = await joinJaasMuc({ name: 'p1', token: moderatorToken });
expect(await moderator.isInMuc()).toBe(true);
expect(await moderator.isModerator()).toBe(true);
expect(await moderator.isVisitor()).toBe(false);
await verifyJoinedWebhook(moderator);
webhooksProxy.clearCache();
// Joining with a participant token before any visitors
const participantToken = t({ room, displayName: 'Parti Cipant' });
const participant = await joinJaasMuc({ name: 'p2', token: participantToken });
expect(await participant.isInMuc()).toBe(true);
expect(await participant.isModerator()).toBe(false);
expect(await participant.isVisitor()).toBe(false);
await verifyJoinedWebhook(participant);
webhooksProxy.clearCache();
// Joining with a visitor token
const visitorToken = t({ room, displayName: 'Visi Tor', visitor: true });
const visitor = await joinJaasMuc({ name: 'p3', token: visitorToken });
expect(await visitor.isInMuc()).toBe(true);
expect(await visitor.isModerator()).toBe(false);
expect(await visitor.isVisitor()).toBe(true);
await verifyJoinedWebhook(visitor);
webhooksProxy.clearCache();
await participant.hangup();
await verifyLeftWebhook(participant);
webhooksProxy.clearCache();
// Joining with a participant token after visitors -> visitor
const participantToken2 = t({ room, displayName: 'Visi Tor 2' });
const visitor2 = await joinJaasMuc({ name: 'p2', token: participantToken2 });
expect(await visitor2.isInMuc()).toBe(true);
expect(await visitor2.isModerator()).toBe(false);
expect(await visitor2.isVisitor()).toBe(true);
await verifyJoinedWebhook(visitor2);
webhooksProxy.clearCache();
await visitor.hangup();
await verifyLeftWebhook(visitor);
webhooksProxy.clearCache();
await visitor2.hangup();
await verifyLeftWebhook(visitor2);
webhooksProxy.clearCache();
await moderator.hangup();
await verifyLeftWebhook(moderator);
});
});

View File

@@ -0,0 +1,73 @@
import { Participant } from '../../../helpers/Participant';
import { setTestProperties } from '../../../helpers/TestProperties';
import { expectations } from '../../../helpers/expectations';
import { joinJaasMuc, generateJaasToken as t } from '../../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Visitors live', () => {
let visitor: Participant, moderator: Participant;
it('setup', async () => {
ctx.webhooksProxy.defaultMeetingSettings = {
visitorsEnabled: true,
visitorsLive: false
};
moderator = await joinJaasMuc({
name: 'p1',
token: t({ room: ctx.roomName, displayName: 'Mo de Rator', moderator: true })
});
if (expectations.jaas.visitors) {
await moderator.driver.waitUntil(() => moderator.execute(() => APP.conference._room.isVisitorsSupported()), {
timeout: 2000
}).catch(e => {
throw new Error(`isVisitorsSupported() returned an error: ${e}.`);
});
}
visitor = await joinJaasMuc({
name: 'p2',
token: t({ room: ctx.roomName, displayName: 'Visi Tor', visitor: true })
}, {
skipWaitToJoin: true
});
});
it('go live', async () => {
const vVisitors = visitor.getVisitors();
const mVisitors = moderator.getVisitors();
await visitor.driver.waitUntil(async () => vVisitors.isVisitorsQueueUIShown(), {
timeout: 5000,
timeoutMsg: 'Missing visitors queue UI'
});
await moderator.driver.waitUntil(async () => await mVisitors.getWaitingVisitorsInQueue()
=== 'Viewers waiting in queue: 1', {
timeout: 15000,
timeoutMsg: 'Missing visitors queue count in UI'
});
await mVisitors.goLive();
await visitor.waitToJoinMUC();
await visitor.waitForReceiveMedia(15000, 'Visitor is not receiving media');
await visitor.waitForRemoteStreams(1);
await visitor.driver.waitUntil(() => vVisitors.hasVisitorsDialog(), {
timeout: 5000,
timeoutMsg: 'Missing visitors dialog'
});
expect((await mVisitors.getVisitorsCount()).trim()).toBe('1');
expect((await mVisitors.getVisitorsHeaderFromParticipantsPane()).trim()).toBe('Viewers 1');
});
});

View File

@@ -0,0 +1,479 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import {
checkForScreensharingTile,
ensureSixParticipants,
ensureThreeParticipants,
hangupAllParticipants
} from '../../helpers/participants';
import { muteAudioAndCheck } from '../helpers/mute';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2', 'p3', 'p4', 'p5', 'p6' ]
});
describe('Active speaker', () => {
it('testActiveSpeaker', async () => {
await ensureThreeParticipants();
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(p1, p2, p3);
// participant3 becomes active speaker - check from participant2's perspective
await testActiveSpeaker(p3, p2, p1);
// participant2 becomes active speaker - check from participant1's perspective
await testActiveSpeaker(p2, p1, p3);
// check the displayed speakers, there should be only one speaker
await assertOneDominantSpeaker(p1);
await assertOneDominantSpeaker(p2);
await assertOneDominantSpeaker(p3);
await hangupAllParticipants();
});
/**
* Test that the dominant speaker appears in the filmstrip in stage view
* even when alphabetically last with limited visible slots.
* This tests the fix for the bug where dominant speakers at the bottom of
* the alphabetically sorted list would not appear when slots were limited.
*
* Note: This test verifies filmstrip ordering via Redux state
* (visibleRemoteParticipants), not large video behavior.
*/
it.skip('testDominantSpeakerInFilmstripWithLimitedSlots', async () => {
await ensureSixParticipants({
configOverwrite: {
startWithAudioMuted: true
}
});
const { p1, p2, p3, p4, p5, p6 } = ctx;
// Resize p1's window to limit filmstrip slots to 2-3 tiles
// This creates the condition where not all participants fit in the filmstrip
await p1.driver.setWindowSize(1024, 600);
await p1.driver.pause(1000); // Wait for layout to adjust
// Set display names to create alphabetical ordering
// Names chosen so p6 ("Zoe") is alphabetically last: Alice, Bob, Charlie, Eve, Frank, Zoe
await setAlphabeticalDisplayNames(p1, p2, p3, p4, p5, p6);
// Test with multiple speakers: Eve (p4), Frank (p5), and Zoe (p6)
// This verifies the fix works for different alphabetical positions
const speakersToTest = [
{ participant: p4, name: 'Eve' },
{ participant: p5, name: 'Frank' },
{ participant: p6, name: 'Zoe' }
];
for (const { participant, name } of speakersToTest) {
const participantId = await participant.getEndpointId();
await p1.log(`Testing ${name} (${participantId}) as dominant speaker`);
// Make this participant the dominant speaker by unmuting
await participant.getToolbar().clickAudioUnmuteButton();
// Wait for the dominant speaker to be detected
await waitForDominantSpeaker(p1, participantId, name);
// Verify that the participant appears in the visible remote participants
const filmstripState = await getFilmstripState(p1);
await p1.log(`Dominant speaker: ${filmstripState.dominantSpeaker}`);
await p1.log(`Visible remote participants: ${JSON.stringify(filmstripState.visibleRemoteParticipants)}`);
await p1.log(`${name} endpoint ID: ${participantId}`);
await p1.log(`Total remote: ${filmstripState.remoteParticipants.length}, Visible: ${filmstripState.visibleRemoteParticipants.length}`);
// Verify we actually have slot limitation (fewer visible than total)
expect(filmstripState.visibleRemoteParticipants.length).toBeLessThan(filmstripState.remoteParticipants.length);
// Assert that the dominant speaker is in the visible participants
// This is the key test - even though they may be alphabetically late and slots are limited,
// they should still be visible because the fix reserves a slot for dominant speaker
expect(filmstripState.visibleRemoteParticipants).toContain(participantId);
// Verify the dominant speaker thumbnail is visible in the filmstrip
await p1.driver.$(`//span[@id='participant_${participantId}']`).waitForDisplayed({
timeout: 5_000,
timeoutMsg: `${name} dominant speaker thumbnail not visible in filmstrip`
});
// Mute this participant back before testing the next one
await participant.getToolbar().clickAudioMuteButton();
await p1.driver.pause(2000);
}
await hangupAllParticipants();
});
/**
* Test dominant speaker in filmstrip with screensharing active.
* Verifies that dominant speaker is still visible when screen shares
* take up some of the visible slots.
*/
it.skip('testDominantSpeakerWithScreensharing', async () => {
await ensureSixParticipants({
configOverwrite: {
startWithAudioMuted: true
}
});
const { p1, p2, p3, p4, p5, p6 } = ctx;
// Resize p1's window to limit filmstrip slots
await p1.driver.setWindowSize(1024, 600);
await p1.driver.pause(1000); // Wait for layout to adjust
// Set display names
await setAlphabeticalDisplayNames(p1, p2, p3, p4, p5, p6);
// Start screensharing from p2
await p2.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p2, p1);
// Test with multiple speakers while screensharing is active
const speakersToTest = [
{ participant: p4, name: 'Eve' },
{ participant: p5, name: 'Frank' },
{ participant: p6, name: 'Zoe' }
];
for (const { participant, name } of speakersToTest) {
const participantId = await participant.getEndpointId();
await p1.log(`Testing ${name} (${participantId}) as dominant speaker with screensharing`);
// Make this participant the dominant speaker by unmuting
await participant.getToolbar().clickAudioUnmuteButton();
// Wait for the dominant speaker to be detected
await waitForDominantSpeaker(p1, participantId, name);
// Verify dominant speaker is still visible in filmstrip despite screenshare
const filmstripState = await getFilmstripState(p1);
await p1.log(`Dominant speaker (with screenshare): ${filmstripState.dominantSpeaker}`);
await p1.log(`Visible remote participants with screenshare: ${JSON.stringify(filmstripState.visibleRemoteParticipants)}`);
await p1.log(`${name} endpoint ID: ${participantId}`);
await p1.log(`Total remote: ${filmstripState.remoteParticipants.length}, Visible: ${filmstripState.visibleRemoteParticipants.length}`);
// Verify we have slot limitation even with screensharing
expect(filmstripState.visibleRemoteParticipants.length).toBeLessThan(filmstripState.remoteParticipants.length);
// The dominant speaker should still be in the visible participants despite screenshare taking slots
expect(filmstripState.visibleRemoteParticipants).toContain(participantId);
// Verify thumbnail visibility
await p1.driver.$(`//span[@id='participant_${participantId}']`).waitForDisplayed({
timeout: 5_000,
timeoutMsg: `${name} not visible with screensharing active`
});
// Mute this participant back before testing the next one
await participant.getToolbar().clickAudioMuteButton();
await p1.driver.pause(1000);
}
// Clean up - stop screensharing
await p2.getToolbar().clickStopDesktopSharingButton();
await hangupAllParticipants();
});
/**
* Test that filmstrip maintains stable ordering when multiple speakers alternate.
* Verifies that the alphabetical sorting prevents visual reordering when the same
* set of speakers take turns speaking.
*/
it.skip('testFilmstripStableOrderingWithMultipleSpeakers', async () => {
await ensureSixParticipants({
configOverwrite: {
startWithAudioMuted: true
}
});
const { p1, p2, p3, p4, p5, p6 } = ctx;
// Resize p1's window to limit filmstrip slots
await p1.driver.setWindowSize(1024, 600);
await p1.driver.pause(1000); // Wait for layout to adjust
// Set display names
await setAlphabeticalDisplayNames(p1, p2, p3, p4, p5, p6);
// First, have Eve, Frank, and Zoe all speak to get them into the active speakers list
const speakersToTest = [
{ participant: p4, name: 'Eve' },
{ participant: p5, name: 'Frank' },
{ participant: p6, name: 'Zoe' }
];
await p1.log('Initial round: getting all three speakers into active speakers list');
for (const { participant, name } of speakersToTest) {
const participantId = await participant.getEndpointId();
await p1.log(`${name} (${participantId}) speaking for the first time`);
await participant.getToolbar().clickAudioUnmuteButton();
await waitForDominantSpeaker(p1, participantId, name);
await participant.getToolbar().clickAudioMuteButton();
await p1.driver.pause(1000);
}
// Now cycle through them again and verify they maintain alphabetical order (Eve, Frank, Zoe)
await p1.log('Second round: verifying stable alphabetical ordering when speakers alternate');
const states = [];
for (const { participant, name } of speakersToTest) {
const participantId = await participant.getEndpointId();
await p1.log(`Testing ${name} (${participantId}) for stable ordering`);
// Make this participant the dominant speaker
await participant.getToolbar().clickAudioUnmuteButton();
// Wait for the dominant speaker to be detected
await waitForDominantSpeaker(p1, participantId, name);
// Capture filmstrip state
const filmstripState = await getFilmstripState(p1);
states.push({ name, id: participantId, state: filmstripState });
// Mute back
await participant.getToolbar().clickAudioMuteButton();
await p1.driver.pause(1000);
}
const [ eveState, frankState, zoeState ] = states;
// Helper function to get participant names in the order they appear
const getVisibleParticipantNames = async (visibleIds: string[]) => {
return await p1.execute(ids => {
const state = APP.store.getState();
const participants = state['features/base/participants'];
return ids.map(id => {
const participant = participants.remote.get(id);
return participant?.name || 'Unknown';
});
}, visibleIds);
};
// Get the names of visible participants for each state
const eveVisibleNames = await getVisibleParticipantNames(eveState.state.visibleRemoteParticipants);
const frankVisibleNames = await getVisibleParticipantNames(frankState.state.visibleRemoteParticipants);
const zoeVisibleNames = await getVisibleParticipantNames(zoeState.state.visibleRemoteParticipants);
await p1.log(`Visible participants when Eve is dominant: ${JSON.stringify(eveVisibleNames)}`);
await p1.log(`Visible participants when Frank is dominant: ${JSON.stringify(frankVisibleNames)}`);
await p1.log(`Visible participants when Zoe is dominant: ${JSON.stringify(zoeVisibleNames)}`);
await p1.log(`Eve visible count: ${eveState.state.visibleRemoteParticipants.length}, total remote: ${eveState.state.remoteParticipants.length}`);
await p1.log(`Frank visible count: ${frankState.state.visibleRemoteParticipants.length}, total remote: ${frankState.state.remoteParticipants.length}`);
await p1.log(`Zoe visible count: ${zoeState.state.visibleRemoteParticipants.length}, total remote: ${zoeState.state.remoteParticipants.length}`);
// Verify that each dominant speaker appears in visible participants
expect(eveState.state.visibleRemoteParticipants).toContain(eveState.id);
expect(frankState.state.visibleRemoteParticipants).toContain(frankState.id);
expect(zoeState.state.visibleRemoteParticipants).toContain(zoeState.id);
// Helper function to get the relative order of Eve, Frank, and Zoe
const getSpeakersOrder = (names: string[]) => {
return names.filter(n => [ 'Eve', 'Frank', 'Zoe' ].includes(n));
};
const eveOrder = getSpeakersOrder(eveVisibleNames);
const frankOrder = getSpeakersOrder(frankVisibleNames);
const zoeOrder = getSpeakersOrder(zoeVisibleNames);
await p1.log(`Speakers order when Eve is dominant: ${JSON.stringify(eveOrder)}`);
await p1.log(`Speakers order when Frank is dominant: ${JSON.stringify(frankOrder)}`);
await p1.log(`Speakers order when Zoe is dominant: ${JSON.stringify(zoeOrder)}`);
// Verify that the dominant speaker is always in the visible list (this tests the bug fix)
expect(eveOrder).toContain('Eve');
expect(frankOrder).toContain('Frank');
expect(zoeOrder).toContain('Zoe');
// Helper to check if array is alphabetically sorted
const isAlphabeticallySorted = (names: string[]) => {
for (let i = 0; i < names.length - 1; i++) {
if (names[i].localeCompare(names[i + 1]) > 0) {
return false;
}
}
return true;
};
// Verify that whatever speakers ARE visible maintain alphabetical order
// This is the key test - when the same speakers alternate, visible speakers stay in alphabetical order
expect(isAlphabeticallySorted(eveOrder)).toBe(true);
expect(isAlphabeticallySorted(frankOrder)).toBe(true);
expect(isAlphabeticallySorted(zoeOrder)).toBe(true);
// Additionally verify order consistency: if multiple speakers are visible in multiple states,
// their relative order should be the same
// For example, if Eve and Frank are both visible when Zoe speaks, they should be [Eve, Frank]
if (eveOrder.includes('Frank') && frankOrder.includes('Eve')) {
// Both Eve and Frank visible in both states
const eveAndFrankInEveState = eveOrder.filter(n => [ 'Eve', 'Frank' ].includes(n));
const eveAndFrankInFrankState = frankOrder.filter(n => [ 'Eve', 'Frank' ].includes(n));
expect(eveAndFrankInEveState).toEqual(eveAndFrankInFrankState);
}
await p1.log('Filmstrip maintains alphabetical ordering of visible speakers when dominant speaker changes');
await hangupAllParticipants();
});
});
/**
* 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.
*
* @param {Participant} activeSpeaker - <tt>Participant</tt> instance of the participant who will be tested as an
* active speaker.
* @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.
*/
async function testActiveSpeaker(
activeSpeaker: Participant, otherParticipant1: Participant, otherParticipant2: Participant) {
await activeSpeaker.log(`Start testActiveSpeaker for participant: ${activeSpeaker.name}`);
const speakerEndpoint = await activeSpeaker.getEndpointId();
// just a debug print to go in logs
await activeSpeaker.log('Unmuting in testActiveSpeaker');
// Unmute
await activeSpeaker.getToolbar().clickAudioUnmuteButton();
// just a debug print to go in logs
await otherParticipant1.log(`Participant unmuted in testActiveSpeaker ${speakerEndpoint}`);
await otherParticipant2.log(`Participant unmuted in testActiveSpeaker ${speakerEndpoint}`);
await activeSpeaker.getFilmstrip().assertAudioMuteIconIsDisplayed(activeSpeaker, true);
// Verify that the user is now an active speaker from otherParticipant1's perspective
const otherParticipant1Driver = otherParticipant1.driver;
await otherParticipant1Driver.waitUntil(
async () => await otherParticipant1.getLargeVideo().getResource() === speakerEndpoint,
{
timeout: 30_000, // 30 seconds
timeoutMsg: 'Active speaker not displayed on large video.'
});
// just a debug print to go in logs
await activeSpeaker.log('Muting in testActiveSpeaker');
// Mute back again
await activeSpeaker.getToolbar().clickAudioMuteButton();
// just a debug print to go in logs
await otherParticipant1.log(`Participant muted in testActiveSpeaker ${speakerEndpoint}`);
await otherParticipant2.log(`Participant muted in testActiveSpeaker ${speakerEndpoint}`);
await otherParticipant1.getFilmstrip().assertAudioMuteIconIsDisplayed(activeSpeaker);
}
/**
* Asserts that the number of small videos with the dominant speaker
* indicator displayed equals 1.
*
* @param {Participant} participant - The participant to check.
*/
async function assertOneDominantSpeaker(participant: Participant) {
expect(await participant.driver.$$(
'//span[not(contains(@class, "tile-view"))]//span[contains(@class,"dominant-speaker")]').length).toBe(1);
}
/**
* Wait for a participant to be detected as the dominant speaker.
*
* @param {Participant} observer - The participant observing the dominant speaker state.
* @param {string} participantId - The endpoint ID of the expected dominant speaker.
* @param {string} participantName - The name of the participant for logging.
*/
async function waitForDominantSpeaker(
observer: Participant,
participantId: string,
participantName: string
): Promise<void> {
await observer.driver.waitUntil(
async () => {
const state = await observer.execute(() => {
const participants = APP.store.getState()['features/base/participants'];
return participants.dominantSpeaker;
});
return state === participantId;
},
{
timeout: 10000,
timeoutMsg: `${participantName} (${participantId}) was not detected as dominant speaker within 10 seconds`
}
);
// Wait a bit more for filmstrip state to update after dominant speaker changes
await observer.driver.pause(1000);
}
/**
* Get the current filmstrip state from Redux.
*
* @param {Participant} participant - The participant to query.
* @returns {Promise<FilmstripState>} The filmstrip state.
*/
async function getFilmstripState(participant: Participant): Promise<{
dominantSpeaker: string | null;
remoteParticipants: string[];
visibleRemoteParticipants: string[];
}> {
return await participant.execute(() => {
const state = APP.store.getState();
const filmstrip = state['features/filmstrip'];
const participants = state['features/base/participants'];
return {
dominantSpeaker: participants.dominantSpeaker,
remoteParticipants: filmstrip.remoteParticipants,
visibleRemoteParticipants: Array.from(filmstrip.visibleRemoteParticipants)
};
});
}
/**
* Set display names for all 6 participants to create alphabetical ordering.
*/
async function setAlphabeticalDisplayNames(
p1: Participant,
p2: Participant,
p3: Participant,
p4: Participant,
p5: Participant,
p6: Participant
): Promise<void> {
await p1.setLocalDisplayName('Alice');
await p2.setLocalDisplayName('Bob');
await p3.setLocalDisplayName('Charlie');
await p4.setLocalDisplayName('Eve');
await p5.setLocalDisplayName('Frank');
await p6.setLocalDisplayName('Zoe');
// Wait for display names to propagate
await p1.driver.pause(2000);
}

View File

@@ -0,0 +1,336 @@
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { expectations } from '../../helpers/expectations';
import {
ensureOneParticipant,
ensureThreeParticipants, ensureTwoParticipants,
hangupAllParticipants
} from '../../helpers/participants';
import { unmuteAudioAndCheck, unmuteVideoAndCheck } from '../helpers/mute';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
describe('Audio/video moderation', () => {
it('setup', async () => {
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
// if all 3 participants are moderators, skip this test
if (!await p1.isModerator()
|| (await p1.isModerator() && await p2.isModerator() && await p3.isModerator())) {
ctx.skipSuiteTests = `Unsupported moderator configuration: p1=${await p1.isModerator()},\
p2=${await p2.isModerator()}, p3=${await p3.isModerator()}`;
}
});
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);
// make sure p1 is not muted after turning on and then off the AV moderation
await p1.getFilmstrip().assertAudioMuteIconIsDisplayed(p1, true);
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p2, true);
});
it('hangup and change moderator', async () => {
// The test below is only correct when the environment is configured to automatically elect a new moderator
// when the moderator leaves. For environments where this is not the case, the test is skipped.
if (!expectations.autoModerator) {
return;
}
await Promise.all([ ctx.p2.hangup(), ctx.p3.hangup() ]);
await ensureThreeParticipants();
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();
// 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.askToUnmute(nonModerator, false);
await nonModerator.getNotifications().waitForAskToUnmuteNotification();
await unmuteAudioAndCheck(nonModerator, p1);
await moderatorParticipantsPane.allowVideo(nonModerator);
await nonModerator.getNotifications().waitForAskToUnmuteNotification();
await unmuteVideoAndCheck(nonModerator, p1);
await moderatorParticipantsPane.clickContextMenuButton();
await moderatorParticipantsPane.getAVModerationMenu().clickStopAudioModeration();
await moderatorParticipantsPane.getAVModerationMenu().clickStopVideoModeration();
});
it('grant moderator', async () => {
await hangupAllParticipants();
await ensureThreeParticipants();
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(
() => p3.isModerator(), {
timeout: 5000,
timeoutMsg: `${p3.name} is not moderator`
});
await unmuteByModerator(p3, p2, false, true);
});
it('ask to unmute', async () => {
await hangupAllParticipants();
await ensureTwoParticipants();
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(p2);
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p2);
// 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);
// close and open participants pane to make sure the context menu disappears
await p1.getParticipantsPane().close();
await p1.getParticipantsPane().open();
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
await tryToVideoUnmuteAndCheck(p2, p1);
});
it('join moderated', async () => {
await hangupAllParticipants();
await ensureOneParticipant();
const p1ParticipantsPane = ctx.p1.getParticipantsPane();
await p1ParticipantsPane.clickContextMenuButton();
await p1ParticipantsPane.getAVModerationMenu().clickStartAudioModeration();
await p1ParticipantsPane.getAVModerationMenu().clickStartVideoModeration();
await p1ParticipantsPane.close();
// join with second participant and check
await ensureTwoParticipants({
skipInMeetingChecks: true
});
const { p1, p2 } = ctx;
await p2.getNotifications().closeYouAreMutedNotification();
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 p1.getFilmstrip().assertAudioMuteIconIsDisplayed(p2);
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p2);
await tryToAudioUnmuteAndCheck(p2, p1);
});
it('mute a participant that reports muted', async () => {
await hangupAllParticipants();
await ensureTwoParticipants();
const { p1, p2 } = ctx;
const p1ParticipantsPane = p1.getParticipantsPane();
// Enable moderation and let p2 unmute. This puts p2 on the whitelist.
await unmuteByModerator(p1, p2, true, false);
// p2 mutes itself. The mute state that p2 reports is not reliable, thus p1 must keep the mute action. The
// mute action is the action that removes p2 from the whitelist and mutes p2 on the bridge.
await p2.getToolbar().clickAudioMuteButton();
await p1.getFilmstrip().assertAudioMuteIconIsDisplayed(p2);
await p1ParticipantsPane.waitForMuteAudioAction(p2);
await p1ParticipantsPane.muteAudio(p2);
await p1ParticipantsPane.close();
// p2 is not on the whitelist any more, thus p2 cannot unmute.
await tryToAudioUnmuteAndCheck(p2, p1);
await p1ParticipantsPane.clickContextMenuButton();
await p1ParticipantsPane.getAVModerationMenu().clickStopAudioModeration();
await p1ParticipantsPane.getAVModerationMenu().clickStopVideoModeration();
await p1ParticipantsPane.close();
});
});
/**
* 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.askToUnmute(participant, false);
await participant.getNotifications().waitForAskToUnmuteNotification();
await unmuteAudioAndCheck(participant, moderator);
await moderatorParticipantsPane.allowVideo(participant);
await participant.getNotifications().waitForAskToUnmuteNotification();
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,146 @@
import { VIDEO_CODEC } from '../../../react/features/video-quality/constants';
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import {
ensureOneParticipant,
ensureThreeParticipants,
ensureTwoParticipants,
hangupAllParticipants
} from '../../helpers/participants';
const { VP8, VP9, AV1 } = VIDEO_CODEC;
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
describe('Codec selection', () => {
it('asymmetric codecs', async () => {
await ensureOneParticipant({
configOverwrite: {
videoQuality: {
codecPreferenceOrder: [ 'VP9', 'VP8', 'AV1' ]
}
}
});
await ensureTwoParticipants({
configOverwrite: {
videoQuality: {
codecPreferenceOrder: [ 'VP8', 'VP9', 'AV1' ]
}
}
});
const { p1, p2 } = ctx;
// Check if media is playing on both endpoints.
expect(await p1.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
expect(await p2.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
// Check if p1 is sending VP9 and p2 is sending VP8 as per their codec preferences.
// Except on older versions of Firefox because they don't support VP9 encode.
const p1ExpectedCodec = supportsVp9Encode(p1) ? VP9 : VP8;
await Promise.all([
waitForCodec(p1, p1ExpectedCodec),
waitForCodec(p2, VP8)
]);
});
it('asymmetric codecs with AV1', async () => {
await ensureThreeParticipants({
configOverwrite: {
videoQuality: {
codecPreferenceOrder: [ 'AV1', 'VP9', 'VP8' ]
}
}
});
const { p1, p2, p3 } = ctx;
// Check if media is playing on p3.
expect(await p3.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
const majorVersion = parseInt(p1.driver.capabilities.browserVersion || '0', 10);
// Check if p1 is encoding in VP9, p2 in VP8 and p3 in AV1 as per their codec preferences.
// Except on older versions of Firefox because they don't support VP9 encode.
const p1ExpectedCodec = supportsVp9Encode(p1) ? VP9 : VP8;
const p3ExpectedCodec = (p1.driver.isFirefox && majorVersion < 136) ? VP9 : AV1;
await Promise.all([
waitForCodec(p1, p1ExpectedCodec),
waitForCodec(p2, VP8),
waitForCodec(p3, p3ExpectedCodec)
]);
});
it('codec switch over', async () => {
await hangupAllParticipants();
await ensureTwoParticipants({
configOverwrite: {
videoQuality: {
codecPreferenceOrder: [ 'VP9', 'VP8', 'AV1' ]
}
}
});
const { p1, p2 } = ctx;
// Disable this test on Firefox because it doesn't support VP9 encode.
if (p1.driver.isFirefox) {
return;
}
// Check if p1 and p2 are encoding in VP9 which is the default codec.
await Promise.all([
waitForCodec(p1, VP9),
waitForCodec(p2, VP9)
]);
await ensureThreeParticipants({
configOverwrite: {
videoQuality: {
codecPreferenceOrder: [ 'VP8' ]
}
}
});
const { p3 } = ctx;
await Promise.all([
waitForCodec(p1, VP8),
waitForCodec(p2, VP8),
waitForCodec(p3, VP8)
]);
await p3.hangup();
// Check of p1 and p2 have switched to VP9.
await Promise.all([
waitForCodec(p1, VP9),
waitForCodec(p2, VP9)
]);
});
});
async function waitForCodec(p: Participant, codec: string) {
await p.driver.waitUntil(
() => p.execute(c => JitsiMeetJS.app.testing.getLocalCameraEncoding() === c, codec),
{
timeout: 10000,
timeoutMsg: `${p.name} failed to use ${codec}`
}
);
}
/**
* Whether the given participant's browser is able to encode VP9. Firefox only supports VP9 encode on version 151 and
* above, see https://bugzilla.mozilla.org/show_bug.cgi?id=1633876.
*
* @param p The participant.
*/
function supportsVp9Encode(p: Participant) {
if (!p.driver.isFirefox) {
return true;
}
return parseInt(p.driver.capabilities.browserVersion || '0', 10) >= 151;
}

View File

@@ -0,0 +1,507 @@
import { SET_LOW_BANDWIDTH_MODE } from '../../../react/features/base/low-bandwidth-mode/actionTypes';
import { setTestProperties } from '../../helpers/TestProperties';
import {
checkForScreensharingTile,
ensureFourParticipants,
ensureOneParticipant,
ensureThreeParticipants,
ensureTwoParticipants,
hangupAllParticipants
} from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2', 'p3', 'p4' ]
});
describe('Desktop sharing', () => {
it('start', async () => {
await ensureTwoParticipants({
configOverwrite: {
p2p: {
backToP2PDelay: 3,
enabled: true
}
}
});
const { p1, p2 } = ctx;
await p1.waitForP2PIceConnected();
await p2.getToolbar().clickDesktopSharingButton();
// Check if a remote screen share tile is created on p1.
await checkForScreensharingTile(p2, p1);
// Check if a local screen share tile is created on p2.
await checkForScreensharingTile(p2, p2);
expect(await p2.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
});
it('stop', async () => {
const { p1, p2 } = ctx;
await p2.getToolbar().clickStopDesktopSharingButton();
// Check if the local screen share thumbnail disappears on p2.
await checkForScreensharingTile(p2, p2, true);
// Check if the remote screen share thumbnail disappears on p1.
await checkForScreensharingTile(p1, p2, true);
});
/**
* Ensures screen share is still visible when the call switches from p2p to jvb connection.
*/
it('p2p to jvb switch', async () => {
await ctx.p2.getToolbar().clickDesktopSharingButton();
await ensureThreeParticipants({
configOverwrite: {
p2p: {
enabled: true
}
}
});
const { p1, p2, p3 } = ctx;
const p2EndpointId = await p2.getEndpointId();
// Check if a remote screen share tile is created on all participants.
await checkForScreensharingTile(p2, p1);
await checkForScreensharingTile(p2, p2);
await checkForScreensharingTile(p2, p3);
await p1.getFilmstrip().assertNoGapsInFilmstrip();
await p3.getFilmstrip().assertNoGapsInFilmstrip();
await p3.waitForParticipantOnLargeVideo(`${p2EndpointId}-v1`);
});
/**
* Ensure screen share is still visible when the call switches from jvb to p2p and back.
*/
it('p2p to jvb switch and back', async () => {
const { p1, p2, p3 } = ctx;
await p3.hangup();
// Wait for p1 and p2 to switch back to p2p.
await p1.waitForP2PIceConnected();
await p2.waitForP2PIceConnected();
// Check if a remote screen share tile is created on p1 and p2 after switching back to p2p.
await checkForScreensharingTile(p2, p1);
await checkForScreensharingTile(p2, p2);
// The video should be playing.
await p1.waitForParticipantOnLargeVideo(`${await p2.getEndpointId()}-v1`);
// Start desktop share on p1.
await p1.getToolbar().clickDesktopSharingButton();
// Check if a new tile for p1's screen share is created on both p1 and p2.
await checkForScreensharingTile(p1, p1);
await checkForScreensharingTile(p1, p2);
await ensureThreeParticipants({
configOverwrite: {
p2p: {
backToP2PDelay: 3,
enabled: true
}
}
});
await checkForScreensharingTile(p1, p3);
await checkForScreensharingTile(p2, p3);
});
/**
* Ensure that screen share is still visible in jvb connection when share is toggled while the users are
* in p2p mode, i.e., share is restarted when user is in p2p mode and then the call switches over to jvb mode.
*/
it('stop screen sharing and back', async () => {
const { p1, p2, p3 } = ctx;
// Stop share on both p1 and p2.
await p1.getToolbar().clickStopDesktopSharingButton();
await p2.getToolbar().clickStopDesktopSharingButton();
await p3.hangup();
// Wait for p1 and p2 to switch back to p2p.
await p1.waitForP2PIceConnected();
await p2.waitForP2PIceConnected();
// Start share on both p1 and p2.
await p1.getToolbar().clickDesktopSharingButton();
await p2.getToolbar().clickDesktopSharingButton();
// Check if p1 and p2 can see each other's shares in p2p.
await checkForScreensharingTile(p1, p2);
await checkForScreensharingTile(p2, p1);
// Add p3 back to the conference and check if p1 and p2's shares are visible on p3.
await ensureThreeParticipants({
configOverwrite: {
p2p: {
backToP2PDelay: 3,
enabled: true
}
}
});
await checkForScreensharingTile(p1, p3);
await checkForScreensharingTile(p2, p3);
// Add another participant to verify multiple screenshares are visible without gaps in filmstrip.
await ensureFourParticipants({
configOverwrite: {
filmstrip: {
stageFilmstripParticipants: 2
},
startWithAudioMuted: true
}
});
const { p4 } = ctx;
await checkForScreensharingTile(p1, p4);
await checkForScreensharingTile(p2, p4);
await p3.getFilmstrip().assertNoGapsInFilmstrip();
await p4.getFilmstrip().assertNoGapsInFilmstrip();
});
/**
* Ensures screen share is visible when a muted screen share track is added to the conference, i.e.,
* users starts and stops the share before anyone else joins the call.
* The call switches to jvb and then back to p2p.
*/
it('screen sharing toggle before others join', async () => {
await hangupAllParticipants();
await ensureOneParticipant({
configOverwrite: {
p2p: {
backToP2PDelay: 3,
enabled: true
},
startWithAudioMuted: false
}
});
const { p1 } = ctx;
// p1 starts share when alone in the call.
await p1.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p1, p1);
// p1 stops share.
await p1.getToolbar().clickStopDesktopSharingButton();
// Call switches to jvb.
await ensureThreeParticipants({
configOverwrite: {
p2p: {
backToP2PDelay: 3,
enabled: true
},
startWithAudioMuted: false
}
});
const { p2, p3 } = ctx;
const p1EndpointId = await p1.getEndpointId();
const p1ScreenShareTileId = `${p1EndpointId}-v1`;
const p2EndpointId = await p2.getEndpointId();
const p2ScreenShareTileId = `${p2EndpointId}-v1`;
// p1 starts share again when call switches to jvb.
await p1.getToolbar().clickDesktopSharingButton();
// Check p2 and p3 are able to see p1's share.
await checkForScreensharingTile(p1, p2);
await checkForScreensharingTile(p1, p3);
await p2.waitForParticipantOnLargeVideo(p1ScreenShareTileId);
await p3.waitForParticipantOnLargeVideo(p1ScreenShareTileId);
// p3 leaves the call.
await p3.hangup();
// Wait for p1 and p2 to switch back to p2p.
await p1.waitForP2PIceConnected();
await p2.waitForP2PIceConnected();
// Make sure p2 see's p1's share after the call switches back to p2p.
await checkForScreensharingTile(p1, p2);
await p2.waitForParticipantOnLargeVideo(p1ScreenShareTileId);
// p2 starts share when in p2p.
await p2.getToolbar().clickDesktopSharingButton();
// Makes sure p2's share is visible on p1.
await checkForScreensharingTile(p2, p1);
await p1.waitForParticipantOnLargeVideo(p2ScreenShareTileId);
});
/**
* A case where a non-dominant speaker is sharing screen for a participant in low bandwidth mode
* where only a screen share can be received. A bug fixed in jvb 0c5dd91b where the video was not received.
*/
it('audio only and non dominant screen share', async () => {
await hangupAllParticipants();
await ensureOneParticipant();
const { p1 } = ctx;
// a workaround to directly set audio only mode without going through the rest of the settings in the UI
await p1.execute(type => {
APP?.store?.dispatch({
type,
lowBandwidthMode: true
});
APP?.conference?.onToggleLowBandwidthMode();
}, SET_LOW_BANDWIDTH_MODE);
await p1.getToolbar().clickAudioMuteButton();
await ensureThreeParticipants({
skipInMeetingChecks: true
});
const { p2, p3 } = ctx;
await p3.getToolbar().clickAudioMuteButton();
await p3.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p3, p1);
await checkForScreensharingTile(p3, p2);
// the video should be playing
await p1.driver.waitUntil(() => p1.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived()), {
timeout: 5_000,
timeoutMsg: 'expected remote screen share to be on large'
});
});
/**
* A case where first participant is muted (a&v) and enters low bandwidth mode,
* the second one is audio muted only and the one sharing (the third) is dominant speaker.
* A problem fixed in jitsi-meet 3657c19e and d6ab0a72.
*/
it('audio only and dominant screen share', async () => {
await hangupAllParticipants();
await ensureOneParticipant({
configOverwrite: {
startWithAudioMuted: true,
startWithVideoMuted: true
}
});
const { p1 } = ctx;
// a workaround to directly set audio only mode without going through the rest of the settings in the UI
await p1.execute(type => {
APP?.store?.dispatch({
type,
lowBandwidthMode: true
});
APP?.conference?.onToggleLowBandwidthMode();
}, SET_LOW_BANDWIDTH_MODE);
await ensureTwoParticipants({
configOverwrite: {
startWithAudioMuted: true,
startWithVideoMuted: false
},
skipInMeetingChecks: true
});
await ensureThreeParticipants({
configOverwrite: {
startWithAudioMuted: false,
startWithVideoMuted: false
},
skipInMeetingChecks: true
});
const { p2, p3 } = ctx;
await p3.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p3, p1);
await checkForScreensharingTile(p3, p2);
// The desktop sharing participant should be on large
expect(await p1.getLargeVideo().getResource()).toBe(`${await p3.getEndpointId()}-v1`);
// the video should be playing
await p1.driver.waitUntil(() => p1.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived()), {
timeout: 5_000,
timeoutMsg: 'expected remote screen share to be on large'
});
});
/**
* Test screensharing with lastN. We add p4 with lastN=2 and verify that it receives the expected streams.
*/
it('with lastN', async () => {
await hangupAllParticipants();
await ensureThreeParticipants({
configOverwrite: {
startWithAudioMuted: false,
startWithVideoMuted: false
},
});
const { p1, p2, p3 } = ctx;
await p3.getToolbar().clickDesktopSharingButton();
await p1.getToolbar().clickAudioMuteButton();
await p3.getToolbar().clickAudioMuteButton();
await ensureFourParticipants({
configOverwrite: {
channelLastN: 2,
startWithAudioMuted: true,
startWithVideoMuted: false
}
});
const { p4 } = ctx;
// We now have p1, p2, p3, p4.
// p3 is screensharing.
// p1, p3, p4 are audio muted, so p2 should eventually become dominant speaker.
// Participants should display p3 on-stage because it is screensharing.
await checkForScreensharingTile(p3, p1);
await checkForScreensharingTile(p3, p2);
await checkForScreensharingTile(p3, p4);
// And the video should be playing
expect(await p4.execute(() => JitsiMeetJS.app.testing.isLargeVideoReceived())).toBe(true);
// Check that there are no gaps in the filmstrip when participant joins during screensharing
await p1.getFilmstrip().assertNoGapsInFilmstrip();
await p2.getFilmstrip().assertNoGapsInFilmstrip();
await p4.getFilmstrip().assertNoGapsInFilmstrip();
const p1EndpointId = await p1.getEndpointId();
const p2EndpointId = await p2.getEndpointId();
// p4 has lastN=2 and has selected p3. With p2 being dominant speaker p4 should eventually
// see video for [p3, p2] and p1 as ninja.
await p4.waitForNinjaIcon(p1EndpointId);
await p4.waitForRemoteVideo(p2EndpointId);
// Let's switch and check, muting participant 2 and unmuting 1 will leave participant 1 as dominant
await p1.getToolbar().clickAudioUnmuteButton();
await p2.getToolbar().clickAudioMuteButton();
// Participant4 should eventually see video for [p3, p1] and p2 as a ninja.
await p4.waitForNinjaIcon(p2EndpointId);
await p4.waitForRemoteVideo(p1EndpointId);
});
/**
* Verifies that when a participant has camera on and starts screenshare, remote participants
* see both the camera tile and the screenshare tile simultaneously.
* Covers: "Turn video & screenshare on — remotes see both" and
* "Camera on before SS starts — receivers still see SS".
*/
it('camera on when screenshare starts — remotes see both tiles', async () => {
await hangupAllParticipants();
await ensureTwoParticipants();
const { p1, p2 } = ctx;
const p2EndpointId = await p2.getEndpointId();
// p2 starts screenshare while camera is already on
await p2.getToolbar().clickDesktopSharingButton();
// p1 should see the screenshare tile for p2
await checkForScreensharingTile(p2, p1);
// p1 should still see the camera video for p2
await p1.waitForRemoteVideo(p2EndpointId);
// p2 should see their own screenshare tile
await checkForScreensharingTile(p2, p2);
await p2.getToolbar().clickStopDesktopSharingButton();
// screenshare tile should be gone, camera should still be visible
await checkForScreensharingTile(p2, p1, true);
await p1.waitForRemoteVideo(p2EndpointId);
});
/**
* Verifies that when a participant joins with video muted, starts screenshare, and then
* enables their camera, remote participants see both tiles simultaneously.
*/
it('join video muted, start screenshare, enable camera — both tiles visible', async () => {
await hangupAllParticipants();
await ensureTwoParticipants();
const { p1, p2 } = ctx;
const p2EndpointId = await p2.getEndpointId();
// Mute p2's camera via toolbar (ensures v0 track exists but is muted)
await p2.getToolbar().clickVideoMuteButton();
await p1.waitForRemoteVideo(p2EndpointId, true);
// p2 starts screenshare while camera is muted
await p2.getToolbar().clickDesktopSharingButton();
// p1 should see the screenshare tile for p2
await checkForScreensharingTile(p2, p1);
// p2 turns on camera
await p2.getToolbar().clickVideoUnmuteButton();
// p1 should now see both the screenshare tile and the camera video
await checkForScreensharingTile(p2, p1);
await p1.waitForRemoteVideo(p2EndpointId);
});
/**
* Verifies that alternating between starting and stopping the camera and screenshare
* in various orders does not break either stream for remote participants.
*/
it('alternating camera and screenshare toggling', async () => {
await hangupAllParticipants();
await ensureTwoParticipants();
const { p1, p2 } = ctx;
const p2EndpointId = await p2.getEndpointId();
// Start SS with camera on — both should be visible
await p2.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p2, p1);
await p1.waitForRemoteVideo(p2EndpointId);
// Stop camera while SS is active — SS should remain visible
await p2.getToolbar().clickVideoMuteButton();
await checkForScreensharingTile(p2, p1);
await p1.waitForRemoteVideo(p2EndpointId, true);
// Re-enable camera while SS is active — both should be visible again
await p2.getToolbar().clickVideoUnmuteButton();
await checkForScreensharingTile(p2, p1);
await p1.waitForRemoteVideo(p2EndpointId);
// Stop SS while camera is on — camera should remain, SS tile gone
await p2.getToolbar().clickStopDesktopSharingButton();
await checkForScreensharingTile(p2, p1, true);
await p1.waitForRemoteVideo(p2EndpointId);
// Start SS again — both should be visible
await p2.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p2, p1);
await p1.waitForRemoteVideo(p2EndpointId);
// Stop camera then SS — both should be gone
await p2.getToolbar().clickVideoMuteButton();
await p2.getToolbar().clickStopDesktopSharingButton();
await checkForScreensharingTile(p2, p1, true);
await p1.waitForRemoteVideo(p2EndpointId, true);
// Re-enable camera to verify it still works after all the toggling
await p2.getToolbar().clickVideoUnmuteButton();
await p1.waitForRemoteVideo(p2EndpointId);
});
});

View File

@@ -0,0 +1,78 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureFourParticipants, ensureThreeParticipants, ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2', 'p3', 'p4' ]
});
describe('LastN', () => {
it('joining the meeting', async () => {
await ensureTwoParticipants({
configOverwrite: {
startWithAudioMuted: true,
startWithVideoMuted: true,
channelLastN: 1
},
skipInMeetingChecks: true
});
await ensureThreeParticipants({
configOverwrite: {
channelLastN: 1
},
skipInMeetingChecks: true,
});
});
it('checks', async () => {
const { p3 } = ctx;
const p3Toolbar = p3.getToolbar();
await p3.waitForSendMedia();
await ctx.p1.waitForRemoteVideo(await p3.getEndpointId());
// Mute audio on participant3.
await p3Toolbar.clickAudioMuteButton();
await ensureFourParticipants({
configOverwrite: {
channelLastN: 1
},
skipInMeetingChecks: true
});
const { p1, p2, p4 } = ctx;
await p4.waitForSendReceiveData();
// Mute audio on p4 and unmute p3.
await p4.getToolbar().clickAudioMuteButton();
await p3Toolbar.clickAudioUnmuteButton();
const p4EndpointId = await p4.getEndpointId();
const p3EndpointId = await p3.getEndpointId();
// Check if p1 starts receiving video from p3 and p4 shows up as ninja.
await p1.waitForNinjaIcon(p4EndpointId);
await p1.waitForRemoteVideo(p3EndpointId);
// At this point, mute video of p3 and others should be receiving p4's video.
// Mute p1's video
await p3Toolbar.clickVideoMuteButton();
await p3.getParticipantsPane().assertVideoMuteIconIsDisplayed(p3);
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p3);
await p1.waitForRemoteVideo(p4EndpointId);
// Unmute p3's video and others should switch to receiving p3's video.
await p3Toolbar.clickVideoUnmuteButton();
await p1.waitForRemoteVideo(p3EndpointId);
await p1.waitForNinjaIcon(p4EndpointId);
// Mute p3's audio and unmute p2's audio. Other endpoints should continue to receive video from p3
// even though p2 is the dominant speaker.
await p3Toolbar.clickAudioMuteButton();
await p2.getToolbar().clickAudioUnmuteButton();
await p1.waitForRemoteVideo(p3EndpointId);
});
});

View File

@@ -0,0 +1,91 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Low bandwidth mode', () => {
it('joining the meeting', () => ensureTwoParticipants());
/**
* Enables low bandwidth mode for p1 and verifies that the other participant sees participant1 as video muted.
*/
it('set and check', () => setLowBandwidthModeAndCheck(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 low bandwidth mode and verifies that both participants see p1 as not video muted.
*/
it('disable and check', () => setLowBandwidthModeAndCheck(false));
/**
* Mutes video on participant1, toggles low bandwidth mode 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 low bandwidth mode.
await setLowBandwidthModeAndCheck(true);
// Disable low bandwidth mode.
await p1.getVideoQualityDialog().setVideoQuality(false);
// p1 should stay muted since it was muted before low bandwidth mode 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);
});
});
/**
* Toggles the low bandwidth mode state of a p1 participant and verifies participant sees the low bandwidth mode
* label and that p2 participant sees a video mute state for the former.
* @param enable
*/
async function setLowBandwidthModeAndCheck(enable: boolean) {
const { p1 } = ctx;
await p1.getVideoQualityDialog().setVideoQuality(enable);
await verifyVideoMute(enable);
await p1.driver.$('//div[@id="videoResolutionLabel"][contains(@class, "low-bandwidth-mode")]')
.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);
}

View File

@@ -0,0 +1,146 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import {
checkForScreensharingTile,
ensureOneParticipant,
ensureTwoParticipants,
joinSecondParticipant,
} from '../../helpers/participants';
import {
muteAudioAndCheck,
unmuteAudioAndCheck,
unmuteVideoAndCheck
} from '../helpers/mute';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Mute', () => {
it('joining the meeting', () => ensureTwoParticipants());
it('mute p1 and check', () => toggleMuteAndCheck(ctx.p1, ctx.p2, true));
it('unmute p1 and check', () => toggleMuteAndCheck(ctx.p1, ctx.p2, false));
it('mute p2 and check', () => toggleMuteAndCheck(ctx.p2, ctx.p1, true));
it('unmute p2 and check', () => toggleMuteAndCheck(ctx.p2, ctx.p1, false));
it('p1 mutes p2 and check', async () => {
const { p1, p2 } = ctx;
if (!await p1.isModerator()) {
return;
}
await p1.getFilmstrip().muteAudio(p2);
// and now check whether second participant is muted
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p2);
});
it('p2 unmute after p1 mute and check', async () => {
const { p1, p2 } = ctx;
await unmuteAudioAndCheck(p2, p1);
});
it('p1 mutes before p2 joins', async () => {
await ctx.p2.hangup();
const { p1 } = ctx;
await p1.getToolbar().clickAudioMuteButton();
await ensureTwoParticipants();
const { p2 } = ctx;
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p1);
await toggleMuteAndCheck(p1, p2, false);
});
it('mute before join and screen share after in p2p', () => muteP1BeforeP2JoinsAndScreenshare(true));
it('mute before join and screen share after with jvb', () => muteP1BeforeP2JoinsAndScreenshare(false));
});
/**
* Toggles the mute state of a specific Meet conference participant and
* verifies that a specific other Meet conference participants sees a
* specific mute state for the former.
* @param testee The participant whose mute state is to be toggled.
* @param observer The participant to verify the mute state of {@code testee}.
* @param muted the mute state of {@code testee} expected to be observed by {@code observer}.
*/
async function toggleMuteAndCheck(
testee: Participant,
observer: Participant,
muted: boolean) {
if (muted) {
await muteAudioAndCheck(testee, observer);
} else {
await unmuteAudioAndCheck(testee, observer);
}
}
/**
* Video mutes participant1 before participant2 joins and checks if participant1 can share or unmute video
* and that media is being received on participant2 in both the cases.
*
* @param p2p whether to enable p2p or not.
*/
async function muteP1BeforeP2JoinsAndScreenshare(p2p: boolean) {
await Promise.all([ ctx.p1?.hangup(), ctx.p2?.hangup() ]);
await ensureOneParticipant({
configOverwrite: {
p2p: {
enabled: p2p
}
}
});
const { p1 } = ctx;
await p1.getToolbar().clickVideoMuteButton();
await joinSecondParticipant({
configOverwrite: {
p2p: {
enabled: p2p
}
}
});
const { p2 } = ctx;
if (p2p) {
await p2.waitForP2PIceConnected();
} else {
await p2.waitForIceConnected();
}
await p2.waitForSendMedia();
// Check if p1 appears video muted on p2.
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
// Start desktop share.
await p1.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p1, p2);
// we need to pass the id of the fake participant we use for the screensharing
await p2.waitForRemoteVideo(`${await p1.getEndpointId()}-v1`);
// Stop desktop share and unmute video and check for video again.
await p1.getToolbar().clickStopDesktopSharingButton();
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await unmuteVideoAndCheck(p1, p2);
await p2.waitForRemoteVideo(await p1.getEndpointId());
await p2.waitForRemoteVideoDecoding(await p1.getEndpointId());
}

View File

@@ -0,0 +1,44 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Pinning', () => {
it('joining the meeting', () => ensureTwoParticipants());
it('p1 click on local', () => ctx.p1.getFilmstrip().pinParticipant(ctx.p1));
it('p1 click on remote', async () => {
await closeToolbarMenu();
const { p1, p2 } = ctx;
await p1.getFilmstrip().pinParticipant(p2);
});
it('p1 unpin remote', () => ctx.p1.getFilmstrip().unpinParticipant(ctx.p2));
it('p2 pin remote', () => ctx.p2.getFilmstrip().pinParticipant(ctx.p1));
it('p2 unpin remote', () => ctx.p2.getFilmstrip().unpinParticipant(ctx.p1));
it('p2 click on local', () => ctx.p2.getFilmstrip().pinParticipant(ctx.p2));
it('p2 click on remote', async () => {
await closeToolbarMenu();
const { p1, p2 } = ctx;
await p2.getFilmstrip().pinParticipant(p1);
});
});
/**
* Closes the overflow menu on both participants.
*/
async function closeToolbarMenu() {
await ctx.p1.getToolbar().closeOverflowMenu();
await ctx.p2.getToolbar().closeOverflowMenu();
}

View File

@@ -0,0 +1,44 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants, hangupAllParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Prejoin disabled', () => {
it('joining with no prejoin - nested object', async () => {
await ensureTwoParticipants({
configOverwrite: {
prejoinConfig: {
enabled: false
}
},
skipInMeetingChecks: true,
});
await checkEveryoneMuted(ctx);
await hangupAllParticipants();
});
it('joining with no prejoin - direct url param ', async () => {
await ensureTwoParticipants({
skipInMeetingChecks: true,
urlAppendString: '&config.prejoinConfig.enabled=false'
});
await checkEveryoneMuted(ctx);
});
});
async function checkEveryoneMuted({ p1, p2 }: { p1: Participant; p2: Participant; }) {
await p1.getFilmstrip().assertAudioMuteIconIsDisplayed(p2);
await p1.getFilmstrip().assertAudioMuteIconIsDisplayed(p1);
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p2);
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p1);
await p1.getParticipantsPane().assertAudioMuteIconIsDisplayed(p2);
await p1.getParticipantsPane().assertAudioMuteIconIsDisplayed(p1);
await p2.getParticipantsPane().assertAudioMuteIconIsDisplayed(p2);
await p2.getParticipantsPane().assertAudioMuteIconIsDisplayed(p1);
}

View File

@@ -0,0 +1,288 @@
import { setTestProperties } from '../../helpers/TestProperties';
import {
checkForScreensharingTile,
ensureOneParticipant,
ensureTwoParticipants,
hangupAllParticipants,
joinSecondParticipant,
joinThirdParticipant
} from '../../helpers/participants';
import { unmuteVideoAndCheck } from '../helpers/mute';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
describe('Start muted', () => {
it('checkboxes test', async () => {
const options = {
configOverwrite: {
p2p: {
enabled: true
},
testing: {
testMode: true,
debugAudioLevels: true
}
} };
await ensureOneParticipant(options);
const { p1 } = ctx;
const p1EndpointId = await p1.getEndpointId();
await p1.getToolbar().clickSettingsButton();
const settingsDialog = p1.getSettingsDialog();
await settingsDialog.waitForDisplay();
await settingsDialog.setStartAudioMuted(true);
await settingsDialog.setStartVideoMuted(true);
await settingsDialog.submit();
// Check that p1 doesn't get muted.
await p1.getFilmstrip().assertAudioMuteIconIsDisplayed(p1, true);
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1, true);
await joinSecondParticipant(options);
// Enable screenshare on p1.
await p1.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p1, p1);
const { p2 } = ctx;
const p2EndpointId = await p2.getEndpointId();
await p2.waitForIceConnected();
await p2.waitForReceiveMedia();
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p2);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
await p1.waitForAudioMuted(p2, true);
await p1.waitForRemoteVideo(p2EndpointId, true);
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p1, true);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1, true);
await p2.waitForAudioMuted(p1, false);
await p2.waitForRemoteVideo(p1EndpointId, false);
// Check if a remote screenshare tile is created on p2.
await checkForScreensharingTile(p1, p2);
// Enable video on p2 and check if p2 appears unmuted on p1.
await Promise.all([
p2.getToolbar().clickAudioUnmuteButton(), p2.getToolbar().clickVideoUnmuteButton()
]);
await p2.getFilmstrip().assertAudioMuteIconIsDisplayed(p2, true);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2, true);
await p1.waitForAudioMuted(p2, false);
await p1.waitForRemoteVideo(p2EndpointId, false);
// Add a third participant and check p3 is able to receive audio and video from p2.
await joinThirdParticipant(options);
const { p3 } = ctx;
await p3.waitForIceConnected();
await p3.waitForReceiveMedia();
await p3.getFilmstrip().assertAudioMuteIconIsDisplayed(p2, true);
await p3.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2, true);
await checkForScreensharingTile(p1, p3);
});
it('config options test', async () => {
await hangupAllParticipants();
const options = {
configOverwrite: {
testing: {
testMode: true,
debugAudioLevels: true
},
startAudioMuted: 2,
startVideoMuted: 2
}
};
await ensureOneParticipant(options);
await joinSecondParticipant(options);
const { p2 } = ctx;
await p2.waitForIceConnected();
await p2.waitForReceiveMedia();
await joinThirdParticipant(options);
const { p3 } = ctx;
await p3.waitForIceConnected();
await p3.waitForReceiveMedia();
const { p1 } = ctx;
const p2ID = await p2.getEndpointId();
await p1.log(`Start configOptionsTest, second participant: ${p2ID}`);
// Participant 3 should be muted, 1 and 2 unmuted.
await p3.getFilmstrip().assertAudioMuteIconIsDisplayed(p3);
await p3.getParticipantsPane().assertVideoMuteIconIsDisplayed(p3);
await Promise.all([
p1.waitForAudioMuted(p3, true),
p2.waitForAudioMuted(p3, true)
]);
await p3.getFilmstrip().assertAudioMuteIconIsDisplayed(p1, true);
await p3.getFilmstrip().assertAudioMuteIconIsDisplayed(p2, true);
await p3.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1, true);
await p3.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2, true);
// Unmute and see if the audio works
// we need 1 and 2 to be muted so we have a dominant speaker event for correct audio levels calculations
await p1.getToolbar().clickAudioMuteButton();
await p2.getToolbar().clickAudioMuteButton();
await p3.getToolbar().clickAudioUnmuteButton();
await p1.log('configOptionsTest, unmuted third participant');
await p1.waitForAudioMuted(p3, false /* unmuted */);
});
it('startWithVideoMuted=true can unmute', async () => {
// Maybe disable if there is FF or Safari participant.
await hangupAllParticipants();
// Explicitly enable P2P due to a regression with unmute not updating
// large video while in P2P.
const options = {
configOverwrite: {
p2p: {
enabled: true
},
startWithVideoMuted: true
}
};
await ensureTwoParticipants(options);
const { p1, p2 } = ctx;
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await Promise.all([
p1.getLargeVideo().waitForSwitchTo(await p2.getEndpointId()),
p2.getLargeVideo().waitForSwitchTo(await p1.getEndpointId())
]);
await unmuteVideoAndCheck(p2, p1);
await p1.getLargeVideo().assertPlaying();
});
it('startWithAudioMuted=true can unmute', async () => {
await hangupAllParticipants();
const options = {
configOverwrite: {
startWithAudioMuted: true,
startWithVideoMuted: false,
testing: {
testMode: true,
debugAudioLevels: true
}
}
};
await ensureTwoParticipants(options);
const { p1, p2 } = ctx;
await Promise.all([ p1.waitForAudioMuted(p2, true), p2.waitForAudioMuted(p1, true) ]);
await p1.getToolbar().clickAudioUnmuteButton();
await Promise.all([ p1.waitForAudioMuted(p2, true), p2.waitForAudioMuted(p1, false) ]);
});
it('startWithAudioVideoMuted=true can unmute', async () => {
await hangupAllParticipants();
const options = {
configOverwrite: {
startWithAudioMuted: true,
startWithVideoMuted: true,
p2p: {
enabled: true
}
}
};
await ensureOneParticipant(options);
await joinSecondParticipant({
configOverwrite: {
testing: {
testMode: true,
debugAudioLevels: true
},
p2p: {
enabled: true
},
startWithAudioMuted: false,
startWithVideoMuted: false
}
});
const { p1, p2 } = ctx;
await p2.waitForIceConnected();
await p2.waitForSendMedia();
await p2.waitForAudioMuted(p1, true);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
// Unmute p1's both audio and video and check on p2.
await p1.getToolbar().clickAudioUnmuteButton();
await p2.waitForAudioMuted(p1, false);
await unmuteVideoAndCheck(p1, p2);
await p2.getLargeVideo().assertPlaying();
});
it('test p2p JVB switch and switch back', async () => {
const { p1, p2 } = ctx;
// Mute p2's video just before p3 joins.
await p2.getToolbar().clickVideoMuteButton();
await joinThirdParticipant({
configOverwrite: {
p2p: {
enabled: true
}
}
});
const { p3 } = ctx;
// Unmute p2 and check if its video is being received by p1 and p3.
await unmuteVideoAndCheck(p2, p3);
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2, true);
// Mute p2's video just before p3 leaves.
await p2.getToolbar().clickVideoMuteButton();
await p3.hangup();
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
await p2.getToolbar().clickVideoUnmuteButton();
// Check if p2's video is playing on p1.
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2, true);
await p1.getLargeVideo().assertPlaying();
});
});

View File

@@ -0,0 +1,61 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants } from '../../helpers/participants';
import { muteVideoAndCheck, unmuteVideoAndCheck } from '../helpers/mute';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Stop video', () => {
it('joining the meeting', () => ensureTwoParticipants());
it('stop video and check', () => muteVideoAndCheck(ctx.p1, ctx.p2));
it('start video and check', () => unmuteVideoAndCheckMedia(ctx.p1, ctx.p2));
it('start video and check stream', async () => {
await muteVideoAndCheck(ctx.p1, ctx.p2);
// now participant2 should be on large video
const largeVideoId = await ctx.p1.getLargeVideo().getId();
await unmuteVideoAndCheckMedia(ctx.p1, ctx.p2);
// check if video stream from second participant is still on large video
expect(largeVideoId).toBe(await ctx.p1.getLargeVideo().getId());
});
it('stop video on participant and check', () => muteVideoAndCheck(ctx.p2, ctx.p1));
it('start video on participant and check', () => unmuteVideoAndCheckMedia(ctx.p2, ctx.p1));
it('stop video on before second joins', async () => {
await ctx.p2.hangup();
const { p1 } = ctx;
await p1.getToolbar().clickVideoMuteButton();
await ensureTwoParticipants();
const { p2 } = ctx;
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await unmuteVideoAndCheckMedia(p1, p2);
});
});
/**
* Unmutes the video of testee and checks that observer sees it unmuted and that observer is decoding frames from it.
* The mute state alone is signalled over the presence, seeing it does not mean that any media is being received.
*
* @param testee The participant that unmutes its video.
* @param observer The participant that should receive and decode the video of testee.
*/
async function unmuteVideoAndCheckMedia(testee: Participant, observer: Participant): Promise<void> {
await unmuteVideoAndCheck(testee, observer);
await observer.waitForRemoteVideoDecoding(await testee.getEndpointId());
}

View File

@@ -0,0 +1,214 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureThreeParticipants, ensureTwoParticipants } from '../../helpers/participants';
import { unmuteVideoAndCheck } from '../helpers/mute';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
const EMAIL = 'support@jitsi.org';
const HASH = '38f014e4b7dde0f64f8157d26a8c812e';
describe('Avatar', () => {
it('setup the meeting', () =>
ensureTwoParticipants({
skipDisplayName: 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.getLargeVideo().getAvatar()).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.getLargeVideo().getAvatar())?.includes(HASH), {
timeout: 2000,
timeoutMsg: 'Avatar on large video did not change'
});
const p1LargeSrc = await p1.getLargeVideo().getAvatar();
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({
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.getLargeVideo().getAvatar() === 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({
skipInMeetingChecks: true
});
const { p3 } = ctx;
// When the first participant is FF because of their audio mic feed it will never become dominant speaker
// and no audio track will be received by the third participant and video is muted,
// that's why we need to do a different check that expects any track just from p2
if (p1.driver.isFirefox) {
await Promise.all([ p2.waitForRemoteStreams(1), p3.waitForRemoteStreams(1) ]);
} else {
await Promise.all([ p2.waitForRemoteStreams(2), p3.waitForRemoteStreams(2) ]);
}
// 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.getLargeVideo().getResource() === 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.getLargeVideo().getResource() === 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 unmuteVideoAndCheck(p1, p2);
});
it('email persistence', async () => {
let { p1 } = ctx;
if (p1.driver.isFirefox) {
// strangely this test when FF is involved, missing source mapping from jvb
// and fails with an error of: expected number of remote streams:1 in 15s for participant1
return;
}
await p1.getToolbar().clickProfileButton();
expect(await p1.getSettingsDialog().getEmail()).toBe(EMAIL);
await p1.hangup();
await ensureTwoParticipants({
skipDisplayName: true
});
p1 = ctx.p1;
await p1.getToolbar().clickProfileButton();
expect(await p1.getSettingsDialog().getEmail()).toBe(EMAIL);
});
});

View File

@@ -0,0 +1,487 @@
import type { ChainablePromiseElement } from 'webdriverio';
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import {
checkSubject,
ensureThreeParticipants,
ensureTwoParticipants,
hangupAllParticipants
} from '../../helpers/participants';
setTestProperties(__filename, {
retry: true,
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
const MAIN_ROOM_NAME = 'Main room';
const BREAKOUT_ROOMS_LIST_ID = 'breakout-rooms-list';
const LIST_ITEM_CONTAINER = 'list-item-container';
describe('Breakout rooms', () => {
it('check support', async () => {
await ensureTwoParticipants();
if (!await ctx.p1.isBreakoutRoomsSupported()) {
ctx.skipSuiteTests = 'The environment does not support breakout rooms.';
}
});
it('add breakout room', async () => {
const { p1, p2 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// there should be no breakout rooms initially, list is sent with a small delay
await p1.driver.pause(2000);
expect(await p1BreakoutRooms.getRoomsCount()).toBe(0);
// add one breakout room
await p1BreakoutRooms.addBreakoutRoom();
await p1.driver.waitUntil(
async () => await p1BreakoutRooms.getRoomsCount() === 1, {
timeout: 5000,
timeoutMsg: 'No breakout room added for p1'
});
// second participant should also see one breakout room
await p2.driver.waitUntil(
async () => await p2.getBreakoutRooms().getRoomsCount() === 1, {
timeout: 5000,
timeoutMsg: 'No breakout room seen by p2'
});
});
it('join breakout room', async () => {
const { p1, p2 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// there should be one breakout room
await p1.driver.waitUntil(
async () => await p1BreakoutRooms.getRoomsCount() === 1, {
timeout: 5000,
timeoutMsg: 'No breakout room seen by p1'
});
const roomsList = await p1BreakoutRooms.getRooms();
expect(roomsList.length).toBe(1);
// join the room
await roomsList[0].joinRoom();
// the participant should see the main room as the only breakout room
await p1.driver.waitUntil(
async () => {
if (await p1BreakoutRooms.getRoomsCount() !== 1) {
return false;
}
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].name === MAIN_ROOM_NAME;
}, {
timeout: 5000,
timeoutMsg: 'P1 did not join breakout room'
});
// the second participant should see one participant in the breakout room
await p2.driver.waitUntil(
async () => {
const list = await p2.getBreakoutRooms().getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].participantCount === 1;
}, {
timeout: 5000,
timeoutMsg: 'P2 is not seeing p1 in the breakout room'
});
});
it('leave breakout room', async () => {
const { p1, p2 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// leave room
await p1BreakoutRooms.leaveBreakoutRoom();
// there should be one breakout room and that should not be the main room
await p1.driver.waitUntil(
async () => {
if (await p1BreakoutRooms.getRoomsCount() !== 1) {
return false;
}
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].name !== MAIN_ROOM_NAME;
}, {
timeout: 5000,
timeoutMsg: 'P1 did not leave breakout room'
});
// the second participant should see no participants in the breakout room
await p2.driver.waitUntil(
async () => {
const list = await p2.getBreakoutRooms().getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].participantCount === 0;
}, {
timeout: 5000,
timeoutMsg: 'P2 is seeing p1 in the breakout room'
});
});
it('remove breakout room', async () => {
const { p1, p2 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// remove the room
await (await p1BreakoutRooms.getRooms())[0].removeRoom();
// there should be no breakout rooms
await p1.driver.waitUntil(
async () => await p1BreakoutRooms.getRoomsCount() === 0, {
timeout: 5000,
timeoutMsg: 'Breakout room was not removed for p1'
});
// the second participant should also see no breakout rooms
await p2.driver.waitUntil(
async () => await p2.getBreakoutRooms().getRoomsCount() === 0, {
timeout: 5000,
timeoutMsg: 'Breakout room was not removed for p2'
});
});
it('auto assign', async () => {
await ensureThreeParticipants();
const { p1, p2 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// create two rooms
await p1BreakoutRooms.addBreakoutRoom();
await p1BreakoutRooms.addBreakoutRoom();
// there should be two breakout rooms
await p1.driver.waitUntil(
async () => await p1BreakoutRooms.getRoomsCount() === 2, {
timeout: 5000,
timeoutMsg: 'Breakout room was not created by p1'
});
// auto assign participants to rooms
await p1BreakoutRooms.autoAssignToBreakoutRooms();
// each room should have one participant
await p1.driver.waitUntil(
async () => {
if (await p1BreakoutRooms.getRoomsCount() !== 2) {
return false;
}
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 2) {
return false;
}
return list[0].participantCount === 1 && list[1].participantCount === 1;
}, {
timeout: 5000,
timeoutMsg: 'P1 did not auto assigned participants to breakout rooms'
});
// the second participant should see one participant in the main room
const p2BreakoutRooms = p2.getBreakoutRooms();
await p2.driver.waitUntil(
async () => {
if (await p2BreakoutRooms.getRoomsCount() !== 2) {
return false;
}
const list = await p2BreakoutRooms.getRooms();
if (list?.length !== 2) {
return false;
}
return list[0].participantCount === 1 && list[1].participantCount === 1
&& (list[0].name === MAIN_ROOM_NAME || list[1].name === MAIN_ROOM_NAME);
}, {
timeout: 5000,
timeoutMsg: 'P2 is not seeing p1 in the main room'
});
});
it('close breakout room', async () => {
const { p1, p2, p3 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// there should be two non-empty breakout rooms
await p1.driver.waitUntil(
async () => {
if (await p1BreakoutRooms.getRoomsCount() !== 2) {
return false;
}
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 2) {
return false;
}
return list[0].participantCount === 1 && list[1].participantCount === 1;
}, {
timeout: 5000,
timeoutMsg: 'P1 is not seeing two breakout rooms'
});
// close the first room
await (await p1BreakoutRooms.getRooms())[0].closeRoom();
// there should be two rooms and first one should be empty
await p1.driver.waitUntil(
async () => {
if (await p1BreakoutRooms.getRoomsCount() !== 2) {
return false;
}
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 2) {
return false;
}
return list[0].participantCount === 0 || list[1].participantCount === 0;
}, {
timeout: 5000,
timeoutMsg: 'P1 is not seeing an empty breakout room'
});
// there should be two participants in the main room, either p2 or p3 got moved to the main room
const checkParticipants = (p: Participant) =>
p.driver.waitUntil(
async () => {
const isInBreakoutRoom = await p.isInBreakoutRoom();
const breakoutRooms = p.getBreakoutRooms();
if (isInBreakoutRoom) {
if (await breakoutRooms.getRoomsCount() !== 2) {
return false;
}
const list = await breakoutRooms.getRooms();
if (list?.length !== 2) {
return false;
}
return list.every(r => { // eslint-disable-line arrow-body-style
return r.name === MAIN_ROOM_NAME ? r.participantCount === 2 : r.participantCount === 0;
});
}
if (await breakoutRooms.getRoomsCount() !== 2) {
return false;
}
const list = await breakoutRooms.getRooms();
if (list?.length !== 2) {
return false;
}
return list[0].participantCount + list[1].participantCount === 1;
}, {
timeout: 5000,
timeoutMsg: `${p.name} is not seeing an empty breakout room and one with one participant`
});
await checkParticipants(p2);
await checkParticipants(p3);
});
it('send participants to breakout room', async () => {
await hangupAllParticipants();
// because the participants rejoin so fast, the meeting is not properly ended,
// so the previous breakout rooms would still be there.
// To avoid this issue we use a different meeting
// Respect room name suffix as it is important in multi-shard testing
ctx.roomName += `new-${ctx.roomName}`;
await ensureTwoParticipants();
const { p1, p2 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// there should be no breakout rooms
expect(await p1BreakoutRooms.getRoomsCount()).toBe(0);
// add one breakout room
await p1BreakoutRooms.addBreakoutRoom();
// there should be one empty room
await p1.driver.waitUntil(
async () => await p1BreakoutRooms.getRoomsCount() === 1
&& (await p1BreakoutRooms.getRooms())[0].participantCount === 0, {
timeout: 5000,
timeoutMsg: 'No breakout room added for p1'
});
// send the second participant to the first breakout room
await p1BreakoutRooms.sendParticipantToBreakoutRoom(p2, (await p1BreakoutRooms.getRooms())[0].name);
// there should be one room with one participant
await p1.driver.waitUntil(
async () => {
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].participantCount === 1;
}, {
timeout: 5000,
timeoutMsg: 'P1 is not seeing p2 in the breakout room'
});
});
it('collapse breakout room', async () => {
const { p1 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// there should be one breakout room with one participant
await p1.driver.waitUntil(
async () => {
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].participantCount === 1;
}, {
timeout: 5000,
timeoutMsg: 'P1 is not seeing p2 in the breakout room'
});
// get id of the breakout room participant
const breakoutList = p1.driver.$(`#${BREAKOUT_ROOMS_LIST_ID}`);
const breakoutRoomItem = await breakoutList.$$(`.${LIST_ITEM_CONTAINER}`).find(
async el => {
const id = await el.getAttribute('id');
return id !== '' && id !== null;
}) as ChainablePromiseElement;
const pId = await breakoutRoomItem.getAttribute('id');
const breakoutParticipant = p1.driver.$(`//div[@id="${pId}"]`);
expect(await breakoutParticipant.isDisplayed()).toBe(true);
// collapse the first
await (await p1BreakoutRooms.getRooms())[0].collapse();
// the participant should not be visible
expect(await breakoutParticipant.isDisplayed()).toBe(false);
// the collapsed room should still have one participant
expect((await p1BreakoutRooms.getRooms())[0].participantCount).toBe(1);
});
it('rename breakout room', async () => {
const myNewRoomName = `breakout-${crypto.randomUUID()}`;
const { p1, p2 } = ctx;
const p1BreakoutRooms = p1.getBreakoutRooms();
// let's rename breakout room and see it in local and remote
await (await p1BreakoutRooms.getRooms())[0].renameRoom(myNewRoomName);
await p1.driver.waitUntil(
async () => {
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].name === myNewRoomName;
}, {
timeout: 5000,
timeoutMsg: 'The breakout room was not renamed for p1'
});
await checkSubject(p2, myNewRoomName);
const p2BreakoutRooms = p2.getBreakoutRooms();
// leave room
await p2BreakoutRooms.leaveBreakoutRoom();
// there should be one empty room
await p1.driver.waitUntil(
async () => {
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].participantCount === 0;
}, {
timeout: 5000,
timeoutMsg: 'The breakout room not found or not empty for p1'
});
await p2.driver.waitUntil(
async () => {
const list = await p2BreakoutRooms.getRooms();
return list?.length === 1;
}, {
timeout: 5000,
timeoutMsg: 'The breakout room not seen by p2'
});
expect((await p2BreakoutRooms.getRooms())[0].name).toBe(myNewRoomName);
// send the second participant to the first breakout room
await p1BreakoutRooms.sendParticipantToBreakoutRoom(p2, myNewRoomName);
// there should be one room with one participant
await p1.driver.waitUntil(
async () => {
const list = await p1BreakoutRooms.getRooms();
if (list?.length !== 1) {
return false;
}
return list[0].participantCount === 1;
}, {
timeout: 5000,
timeoutMsg: 'The breakout room was not rename for p1'
});
await checkSubject(p2, myNewRoomName);
});
});

View File

@@ -0,0 +1,255 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { expectations } from '../../helpers/expectations';
import { joinMuc, waitForMedia } from '../../helpers/joinMuc';
setTestProperties(__filename, {
description: 'Tests ICE connectivity for JVB, P2P, and TURN (relay) modes.',
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Connectivity - JVB', () => {
let p1: Participant, p2: Participant;
before(async function() {
if (!expectations.connectivity.jvb.direct) {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.skip();
}
});
it('setup', async () => {
[ p1, p2 ] = await joinParticipants({ p2p: { enabled: false } });
await waitForMedia([ p1, p2 ]);
});
it('uses UDP', async () => {
const [ t1, t2 ] = await getPairs([ p1, p2 ], false);
expect(t1?.protocol?.toLowerCase()).toBe('udp');
expect(t2?.protocol?.toLowerCase()).toBe('udp');
});
it('both clients connect to the same JVB port', async () => {
const [ t1, t2 ] = await getPairs([ p1, p2 ], false);
const port1 = parsePort(t1?.ip);
const port2 = parsePort(t2?.ip);
expect(port1).toBeInteger('p1 remote port');
expect(port2).toBeInteger('p2 remote port');
expect(port1).toBe(port2);
});
it('not using TURN', async () => assertTurn([ p1, p2 ], false, false));
it('not P2P', async () => {
expect(await isP2PActive(p1)).toBe(false);
});
});
describe('Connectivity - P2P', () => {
let p1: Participant, p2: Participant;
before(async function() {
if (!expectations.connectivity.p2p.direct) {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.skip();
}
});
it('setup', async () => {
[ p1, p2 ] = await joinParticipants({
p2p: { enabled: true, useStunTurn: false },
disable1On1Mode: false
});
await waitForP2P([ p1, p2 ]);
await waitForMedia([ p1, p2 ]);
});
it('is P2P active', async () => assertP2PActive([ p1, p2 ], true));
it('not using TURN', async () => assertTurn([ p1, p2 ], true, false));
});
describe('Connectivity - JVB + TURN', () => {
let p1: Participant, p2: Participant;
let t1: Record<string, any> | null, t2: Record<string, any> | null;
before(async function() {
if (!expectations.connectivity.jvb.turn) {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.skip();
}
});
it('setup', async () => {
[ p1, p2 ] = await joinParticipants({ p2p: { enabled: false }, forceTurnRelay: true });
await waitForMedia([ p1, p2 ]);
[ t1, t2 ] = await getPairs([ p1, p2 ], false);
console.log('p1 active JVB pair:', JSON.stringify(t1));
console.log('p2 active JVB pair:', JSON.stringify(t2));
});
it('uses TURN', async () => assertTurn([ p1, p2 ], false, true));
it('both clients connect to the same JVB port', async () => {
const port1 = parsePort(t1?.ip);
const port2 = parsePort(t2?.ip);
expect(port1).toBeInteger('p1 remote port');
expect(port2).toBeInteger('p2 remote port');
expect(port1).toBe(port2);
});
it('not P2P', async () => {
expect(await isP2PActive(p1)).toBe(false);
});
});
describe('Connectivity - P2P + TURN', () => {
let p1: Participant, p2: Participant;
before(async function() {
if (!expectations.connectivity.p2p.turn) {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.skip();
}
});
it('setup', async () => {
[ p1, p2 ] = await joinParticipants({
p2p: { enabled: true, useStunTurn: true },
disable1On1Mode: false,
forceTurnRelay: true
});
await waitForP2P([ p1, p2 ]);
await waitForMedia([ p1, p2 ]);
});
it('is P2P active', async () => assertP2PActive([ p1, p2 ], true));
it('uses TURN', async () => assertTurn([ p1, p2 ], true, true));
});
async function joinParticipants(configOverwrite: object): Promise<[ Participant, Participant ]> {
const joinOptions = { configOverwrite };
const p1 = await joinMuc({ name: 'p1', token: testsConfig.jwt.preconfiguredToken }, joinOptions);
const p2 = await joinMuc({ name: 'p2', token: testsConfig.jwt.preconfiguredToken }, joinOptions);
return [ p1, p2 ];
}
async function waitForP2P(participants: Participant[]): Promise<void> {
await Promise.all(participants.map(p => p.waitForP2PIceConnected()));
}
async function getPairs(
participants: Participant[], p2p: boolean): Promise<(Record<string, any> | null)[]> {
return Promise.all(participants.map(p => getActiveCandidatePair(p, p2p)));
}
async function assertTurn(participants: Participant[], p2p: boolean, expected: boolean): Promise<void> {
for (const p of participants) {
expect(await isUsingTurn(p, p2p)).toBe(expected);
}
}
async function assertP2PActive(participants: Participant[], expected: boolean): Promise<void> {
for (const p of participants) {
expect(await isP2PActive(p)).toBe(expected);
}
}
/**
* Returns the active candidate pair stats from the RTCPeerConnection for the given session. The active pair is
* resolved from the transport's `selectedCandidatePairId` (falling back to the highest-traffic nominated/succeeded
* pair), since the cached transport array in getStats() accumulates all pairs ever used. Right after ICE connects the
* selected pair can briefly report `state: 'in-progress'`, so we poll getStats() until an active pair is available.
*/
async function getActiveCandidatePair(participant: Participant, p2p: boolean): Promise<Record<string, any> | null> {
return participant.execute(async (isP2P: boolean) => {
const room = APP?.conference?._room;
const session = isP2P ? room?.p2pJingleSession : room?.jvbJingleSession;
const pc = session?.peerconnection?.peerconnection;
if (!pc) {
return null;
}
const deadline = performance.now() + 5000;
do {
// eslint-disable-next-line no-await-in-loop
const stats = await pc.getStats();
const candidates: Map<string, any> = new Map();
const succeededPairs: any[] = [];
let transport: any = null;
stats.forEach((report: any) => {
if (report.type === 'local-candidate' || report.type === 'remote-candidate') {
candidates.set(report.id, report);
} else if (report.type === 'transport') {
transport = report;
} else if (report.type === 'candidate-pair' && report.nominated && report.state === 'succeeded') {
succeededPairs.push(report);
}
});
// Prefer the transport's selected pair; fall back to the highest-traffic nominated/succeeded pair.
const selectedId = transport?.selectedCandidatePairId;
const activePair = (selectedId && stats.get(selectedId))
|| succeededPairs.sort((a, b) => b.bytesSent - a.bytesSent)[0];
if (activePair) {
const local = candidates.get(activePair.localCandidateId);
const remote = candidates.get(activePair.remoteCandidateId);
const localIp = local?.ip ?? local?.address;
const remoteIp = remote?.ip ?? remote?.address;
return {
localip: `${localIp}:${local?.port}`,
ip: `${remoteIp}:${remote?.port}`,
localCandidateType: local?.candidateType,
localCandidateUrl: local?.url,
localRelayProtocol: local?.relayProtocol,
remoteCandidateType: remote?.candidateType,
protocol: remote?.protocol,
rtt: activePair.currentRoundTripTime * 1000
};
}
// eslint-disable-next-line no-await-in-loop
await new Promise(resolve => {
setTimeout(resolve, 200);
});
} while (performance.now() < deadline);
return null;
}, p2p);
}
function parsePort(address: string): number {
const parts = address.split(':');
return parseInt(parts[parts.length - 1], 10);
}
/**
* Returns true if the active candidate pair uses a TURN relay. No single field is reliable across browsers: Chrome
* sometimes reports the local candidate type as `prflx` (peer reflexive) rather than `relay` for a relayed candidate,
* and Firefox does not populate the `url` field. We therefore consider the candidate to be relayed if any of the
* following holds: the candidate type is `relay`, its URL is a `turn:`/`turns:` URL, or it has a `relayProtocol`
* (which is only ever set on candidates allocated through a TURN server).
*/
async function isUsingTurn(participant: Participant, p2p: boolean): Promise<boolean> {
const pair = await getActiveCandidatePair(participant, p2p);
return pair?.localCandidateType === 'relay'
|| /^turns?:/.test(pair?.localCandidateUrl ?? '')
|| [ 'udp', 'tcp', 'tls' ].includes(pair?.localRelayProtocol);
}
async function isP2PActive(participant: Participant): Promise<boolean> {
return participant.execute(() => APP?.conference?._room?.isP2PActive());
}

View File

@@ -0,0 +1,125 @@
import process from 'node:process';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { expectations } from '../../helpers/expectations';
import { ensureOneParticipant } from '../../helpers/participants';
import {
assertDialInDisplayed,
assertUrlDisplayed,
cleanup,
dialIn,
isDialInEnabled, verifyMoreNumbersPage,
waitForAudioFromDialInParticipant
} from '../helpers/DialIn';
setTestProperties(__filename, {
usesBrowsers: [ 'p1' ]
});
/**
* This test is configured with two options:
* 1. The dialIn.enabled expectation. If set to true we assert the config.js settings for dial-in are set, the dial-in
* panel (including the PIN number) is displayed in the UI, and the "more numbers" page is displayed. If it's set to
* false we assert the config.js settings are not set, and the PIN is not displayed.
* 2. The DIAL_IN_REST_URL environment variable. If this is set and the environment supports dial-in, we invite a
* dial-in participant via this URL and assert that it joins the conference and sends media.
*/
describe('Dial-in', () => {
it('join participant', async () => {
// The same cases are covered for JaaS in jaas/dial/dialin.spec.ts.
if (testsConfig.jaas.enabled) {
ctx.skipSuiteTests = 'JaaS is configured.';
return;
}
await ensureOneParticipant();
expect(await ctx.p1.isInMuc()).toBe(true);
});
it('dial in config.js values', async function() {
if (expectations.dialIn.enabled === true) {
expect(await isDialInEnabled(ctx.p1)).toBe(expectations.dialIn.enabled);
} else {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.skip();
}
});
it('open/close invite dialog', async () => {
await ctx.p1.getInviteDialog().open();
await ctx.p1.getInviteDialog().clickCloseButton();
await ctx.p1.getInviteDialog().waitTillOpen(true);
});
it('dial-in displayed', async function() {
if (expectations.dialIn.enabled !== null) {
await assertDialInDisplayed(ctx.p1, expectations.dialIn.enabled);
} else {
// eslint-disable-next-line @typescript-eslint/no-invalid-this
this.skip();
}
});
it('skip the rest if dial-in is not expected', async () => {
if (!expectations.dialIn.enabled) {
ctx.skipSuiteTests = 'Dial-in is not expected';
}
});
it('url displayed', () => assertUrlDisplayed(ctx.p1));
it('view more numbers page', async () => {
if (expectations.dialIn.moreNumbersPage) {
await verifyMoreNumbersPage(ctx.p1);
}
});
it('retrieve pin', async () => {
let dialInPin: string;
try {
dialInPin = await ctx.p1.getDialInPin();
} catch (e) {
console.error('dial-in.test.no-pin');
ctx.skipSuiteTests = 'No dial-in pin is available.';
throw e;
}
if (dialInPin.length === 0) {
console.error('dial-in.test.no-pin');
ctx.skipSuiteTests = 'The dial-in pin is empty.';
throw new Error('no pin');
}
if (!dialInPin.match(/^[0-9]+$/)) {
throw new Error(`The dial-in PIN contains non-digit characters: ${dialInPin}`);
}
expect(dialInPin.length).toBeGreaterThanOrEqual(expectations.dialIn.minPinLength);
});
it('skip the rest if a dial-in URL is not configured', async () => {
if (!process.env.DIAL_IN_REST_URL) {
ctx.skipSuiteTests = 'DIAL_IN_REST_URL is not set.';
}
});
it('invite dial-in participant', async () => {
await dialIn(await ctx.p1.getDialInPin());
});
it('wait for audio from dial-in participant', async () => {
const { p1 } = ctx;
if (!await p1.isInMuc()) {
// local participant did not join abort
return;
}
await waitForAudioFromDialInParticipant(p1);
await cleanup(p1);
});
});

View File

@@ -0,0 +1,98 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureThreeParticipants, ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
describe('Follow me', () => {
it('joining the meeting', async () => {
await ensureTwoParticipants();
const { p1 } = ctx;
await p1.getToolbar().clickSettingsButton();
const settings = p1.getSettingsDialog();
await settings.waitForDisplay();
await settings.setFollowMe(true);
await settings.submit();
});
it('follow me checkbox visible only for moderators', async () => {
const { p2 } = ctx;
if (!await p2.isModerator()) {
await p2.getToolbar().clickSettingsButton();
const settings = p2.getSettingsDialog();
await settings.waitForDisplay();
expect(await settings.isFollowMeDisplayed()).toBe(false);
await settings.clickCloseButton();
}
});
it('filmstrip commands', async () => {
const { p1, p2 } = ctx;
const p1Filmstrip = p1.getFilmstrip();
const p2Filmstrip = p2.getFilmstrip();
await p1Filmstrip.toggle();
await p1Filmstrip.assertRemoteVideosHidden();
await p2Filmstrip.assertRemoteVideosHidden();
});
it('tile view', async () => {
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
await p1.waitForTileViewDisplayed();
await p1.getToolbar().clickExitTileViewButton();
await Promise.all([
p1.waitForTileViewDisplayed(true),
p2.waitForTileViewDisplayed(true),
p3.waitForTileViewDisplayed(true)
]);
await p1.getToolbar().clickEnterTileViewButton();
await Promise.all([
p1.waitForTileViewDisplayed(),
p2.waitForTileViewDisplayed(),
p3.waitForTileViewDisplayed()
]);
});
it('next on stage', async () => {
const { p1, p2, p3 } = ctx;
await p1.getFilmstrip().pinParticipant(p2);
const p2Filmstrip = p2.getFilmstrip();
const localVideoId = await p2Filmstrip.getLocalVideoId();
await p2.driver.waitUntil(
async () => await localVideoId === await p2.getLargeVideo().getId(),
{
timeout: 5_000,
timeoutMsg: 'The pinned participant is not displayed on stage for p2'
});
const p2VideoIdOnp3 = await p3.getFilmstrip().getRemoteVideoId(await p2.getEndpointId());
await p3.driver.waitUntil(
async () => p2VideoIdOnp3 === await p3.getLargeVideo().getId(),
{
timeout: 5_000,
timeoutMsg: 'The pinned participant is not displayed on stage for p3'
});
});
});

View File

@@ -0,0 +1,128 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Polls', () => {
it('joining the meeting', async () => {
await ensureTwoParticipants();
});
it('create poll', async () => {
const { p1 } = ctx;
await p1.getToolbar().clickChatButton();
expect(await p1.getChatPanel().isOpen()).toBe(true);
expect(await p1.getChatPanel().isPollsTabVisible()).toBe(false);
await p1.getChatPanel().openPollsTab();
expect(await p1.getChatPanel().isPollsTabVisible()).toBe(true);
// create poll
await p1.getChatPanel().clickCreatePollButton();
await p1.getChatPanel().waitForNewPollInput();
});
it('fill in poll', async () => {
const { p1 } = ctx;
await p1.getChatPanel().fillPollQuestion('My Poll question?');
await p1.getChatPanel().waitForOptionInput(0);
await p1.getChatPanel().waitForOptionInput(1);
await p1.getChatPanel().fillPollOption(0, 'First option');
await p1.getChatPanel().fillPollOption(1, 'Second option');
await p1.getChatPanel().clickAddOptionButton();
await p1.getChatPanel().waitForOptionInput(2);
await p1.getChatPanel().fillPollOption(2, 'Third option');
await p1.getChatPanel().clickAddOptionButton();
await p1.getChatPanel().waitForOptionInput(3);
await p1.getChatPanel().fillPollOption(3, 'Fourth option');
await p1.getChatPanel().clickRemoveOptionButton(2);
// we remove the option and reindexing happens, so we check for index 3
await p1.getChatPanel().waitForOptionInputNonExisting(3);
expect(await p1.getChatPanel().getOption(2)).toBe('Fourth option');
});
it('save and edit poll', async () => {
const { p1 } = ctx;
await p1.getChatPanel().clickSavePollButton();
await p1.getChatPanel().waitForSendButton();
await p1.getChatPanel().clickEditPollButton();
await p1.getChatPanel().fillPollOption(0, ' edited!');
await p1.getChatPanel().clickSavePollButton();
await p1.getChatPanel().waitForSendButton();
});
it('send poll', async () => {
const { p1 } = ctx;
await p1.getChatPanel().clickSendPollButton();
});
it('vote on poll', async () => {
const { p1 } = ctx;
// await p1.getNotifications().closePollsNotification();
// we have only one poll, so we get its ID
const pollId: string = await p1.driver.waitUntil(() => p1.driver.execute(() => {
return Object.keys(APP.store.getState()['features/polls'].polls)[0];
}), { timeout: 2000 });
// we have just send the poll, so the UI should be in a state for voting
await p1.getChatPanel().voteForOption(pollId, 0);
});
it('check for vote', async () => {
const { p1, p2 } = ctx;
const pollId: string = await p1.driver.execute('return Object.keys(APP.store.getState()["features/polls"].polls)[0];');
// now let's check on p2 side
await p2.getToolbar().clickChatButton();
expect(await p2.getChatPanel().isOpen()).toBe(true);
expect(await p2.getChatPanel().isPollsTabVisible()).toBe(false);
await p2.getChatPanel().openPollsTab();
expect(await p2.getChatPanel().isPollsTabVisible()).toBe(true);
expect(await p2.getChatPanel().isPollVisible(pollId));
await p2.getChatPanel().clickSkipPollButton();
expect(await p2.getChatPanel().getResult(pollId, 0)).toBe('1 (100%)');
});
it('leave and check for vote', async () => {
await ctx.p2.hangup();
await ensureTwoParticipants();
const { p1, p2 } = ctx;
const pollId: string = await p1.driver.execute('return Object.keys(APP.store.getState()["features/polls"].polls)[0];');
await p2.getToolbar().clickChatButton();
await p2.getChatPanel().openPollsTab();
expect(await p2.getChatPanel().isPollVisible(pollId));
await p2.getChatPanel().clickSkipPollButton();
expect(await p2.getChatPanel().getResult(pollId, 0)).toBe('1 (100%)');
});
});

View File

@@ -0,0 +1,143 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1' ]
});
const REACTION_SHORTCUT_CHARACTERS = [ 'T', 'C', 'L', 'O', 'B', 'S', 'H' ];
/**
* Returns the currently registered keyboard shortcut keys.
*/
function getRegisteredShortcutKeys() {
return ctx.p1.execute(() =>
Array.from(APP.store.getState()['features/keyboard-shortcuts'].shortcuts.keys()));
}
/**
* Returns the persisted Ctrl+Alt reaction shortcut preference.
*/
function getPersistedCtrlAltReactionShortcutsPreference() {
return ctx.p1.execute(() => {
const persistedState = localStorage.getItem('features/keyboard-shortcuts');
return persistedState
? JSON.parse(persistedState).ctrlAltReactionShortcutsEnabled
: undefined;
});
}
/**
* Dispatches a Ctrl+Alt+T key event and returns the reactions it adds to the buffer.
*/
function sendCtrlAltT() {
return ctx.p1.execute(() => {
const activeElement = document.activeElement as HTMLElement;
activeElement?.blur();
const buffer = APP.store.getState()['features/reactions'].buffer;
const originalLength = buffer.length;
window.dispatchEvent(new KeyboardEvent('keyup', {
altKey: true,
code: 'KeyT',
ctrlKey: true,
key: 't'
}));
return APP.store.getState()['features/reactions'].buffer.slice(originalLength);
});
}
describe('Reaction keyboard shortcut aliases', () => {
it('joins the meeting', async () => {
await ensureOneParticipant();
});
it('registers Ctrl+Alt aliases only on Firefox', async () => {
const isFirefox = await ctx.p1.execute(() => navigator.userAgent.includes('Firefox/'));
const shortcuts = await getRegisteredShortcutKeys();
REACTION_SHORTCUT_CHARACTERS.forEach(character => {
expect(shortcuts).toContain(`:${character}`);
if (isFirefox) {
expect(shortcuts).toContain(`-:${character}`);
} else {
expect(shortcuts).not.toContain(`-:${character}`);
}
});
if (isFirefox) {
expect(await sendCtrlAltT()).toEqual([ 'like' ]);
}
});
it('shows and saves the Firefox-only setting', async () => {
const { p1 } = ctx;
const isFirefox = await p1.execute(() => navigator.userAgent.includes('Firefox/'));
await p1.getToolbar().clickSettingsButton();
const settings = p1.getSettingsDialog();
await settings.waitForDisplay();
await settings.openShortcutsTab();
expect(await settings.hasCtrlAltReactionShortcutsSetting()).toBe(isFirefox);
if (!isFirefox) {
await settings.clickCloseButton();
return;
}
expect(await settings.isCtrlAltReactionShortcutsEnabled()).toBe(true);
const shortcutLabels = await settings.getShortcutKeyLabels();
REACTION_SHORTCUT_CHARACTERS.forEach(character => {
expect(shortcutLabels).toContain(`Alt + ${character}`);
expect(shortcutLabels).toContain(`Ctrl + Alt + ${character}`);
});
await settings.setCtrlAltReactionShortcutsEnabled(false);
const disabledShortcutLabels = await settings.getShortcutKeyLabels();
REACTION_SHORTCUT_CHARACTERS.forEach(character => {
expect(disabledShortcutLabels).toContain(`Alt + ${character}`);
expect(disabledShortcutLabels).not.toContain(`Ctrl + Alt + ${character}`);
});
await settings.submit();
await p1.driver.waitUntil(async () => {
const shortcuts = await getRegisteredShortcutKeys();
return REACTION_SHORTCUT_CHARACTERS.every(character =>
shortcuts.includes(`:${character}`) && !shortcuts.includes(`-:${character}`));
});
await p1.driver.waitUntil(async () =>
await getPersistedCtrlAltReactionShortcutsPreference() === false, { timeout: 3000 });
expect(await sendCtrlAltT()).toEqual([]);
await p1.getToolbar().clickSettingsButton();
await settings.waitForDisplay();
await settings.openShortcutsTab();
expect(await settings.isCtrlAltReactionShortcutsEnabled()).toBe(false);
await settings.setCtrlAltReactionShortcutsEnabled(true);
await settings.submit();
await p1.driver.waitUntil(async () => {
const shortcuts = await getRegisteredShortcutKeys();
return REACTION_SHORTCUT_CHARACTERS.every(character => shortcuts.includes(`-:${character}`));
});
await p1.driver.waitUntil(async () =>
await getPersistedCtrlAltReactionShortcutsPreference() === true, { timeout: 3000 });
});
});

View File

@@ -0,0 +1,94 @@
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { expectations } from '../../helpers/expectations';
import { ensureOneParticipant, ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
description: 'Recording button is shown to moderators and hidden from non-moderators',
usesBrowsers: [ 'p1', 'p2' ],
useJaas: true
});
describe('Recording button visibility', () => {
let p1: Participant, p2: Participant;
let localRecordingAvailable: boolean;
it('setup', async () => {
if (!expectations.jaas.recordingEnabled) {
ctx.skipSuiteTests = 'Recording is not enabled in this environment';
return;
}
if (expectations.moderation.allModerators) {
ctx.skipSuiteTests = 'Cannot test non-moderator visibility when all participants are moderators';
return;
}
await ensureOneParticipant();
p1 = ctx.p1;
expect(await p1.isModerator()).toBe(true);
await ensureTwoParticipants();
p2 = ctx.p2;
expect(await p2.isModerator()).toBe(false);
// Mirror the app's supportsLocalRecording() exactly — LocalRecordingManager.isSupported()
// plus the (!isEmbedded() || isEmbeddedFromSameDomain()) guard — so the test's expectation
// matches what the app actually renders. These browser/capability gates are frequently unmet
// in the automated test browser even though local recording works in a normal browser, in
// which case the app correctly hides the button from non-moderators.
localRecordingAvailable = await p2.execute(() => {
const state = APP.store.getState();
const { localRecording } = state['features/base/config'];
const browser = JitsiMeetJS.util.browser;
const PREFERRED_MEDIA_TYPE = 'video/webm;codecs=vp8,opus';
// LocalRecordingManager.isSupported()
const isSupported = browser.isChromiumBased()
&& !browser.isElectron()
&& !browser.isReactNative()
&& !browser.isMobileDevice()
// @ts-ignore
&& Boolean(navigator.mediaDevices.setCaptureHandleConfig)
// @ts-ignore
&& typeof window.showSaveFilePicker !== 'undefined'
&& MediaRecorder.isTypeSupported(PREFERRED_MEDIA_TYPE);
// (!isEmbedded() || isEmbeddedFromSameDomain())
let embeddedOk = true;
try {
if (window.self !== window.top) {
embeddedOk = window.self.location.host === window.parent.location.host;
}
} catch (e) {
embeddedOk = false;
}
return localRecording?.disable !== true && isSupported && embeddedOk;
});
});
it('moderator sees the recording button', async () => {
expect(await p1.getToolbar().hasRecordingButton()).toBe(true);
});
it('non-moderator does not see the recording button when local recording is disabled', async () => {
if (localRecordingAvailable) {
return;
}
expect(await p2.getToolbar().hasRecordingButton()).toBe(false);
});
it('non-moderator sees the recording button when local recording is enabled', async () => {
if (!localRecordingAvailable) {
return;
}
expect(await p2.getToolbar().hasRecordingButton()).toBe(true);
});
});

View File

@@ -0,0 +1,371 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { joinMuc } from '../../helpers/joinMuc';
/**
* Joins (or rejoins) p1 as a moderator with the given config.js overrides, using the shared
* preconfigured token (see TestsConfig.ts) rather than a self-signed one. This suite only ever
* varies config.js, never JWT feature claims — on this (non-JaaS) tenant, getRecordButtonProps()
* and friends fall back to config alone once the JWT carries no feature claims, so the shared
* token is enough. The JWT-feature-driven side of this same dialog (only reachable on a JaaS
* tenant, where that config-only fallback doesn't apply) is covered separately by
* tests/specs/jaas/recordingDialogConfig.spec.ts.
*
* A fresh join is required (rather than ensureOneParticipant, which no-ops if already in the
* conference) because each test in this file exercises a different config.js combination.
*
* Waits for p1's moderator role to actually land client-side before returning: the server grants
* it (the JWT's moderator claim) on every join, but the toolbar's recording button renders the
* non-moderator "Record" label (instead of "Record & Transcribe") until that presence update is
* processed — a gap the rest of this file otherwise has no reason to guard against itself.
*/
async function joinWithConfig(configOverwrite: Record<string, unknown>): Promise<void> {
await joinMuc({
name: 'p1',
token: testsConfig.jwt.preconfiguredToken
}, { configOverwrite });
await ctx.p1.driver.waitUntil(() => ctx.p1.isModerator(), {
timeout: 5_000,
timeoutMsg: 'p1 did not become a moderator in time'
});
}
/**
* Mirrors LocalRecordingManager.isSupported() plus the embedding guard that
* supportsLocalRecording() adds — the same probe recordingButtonVisibility.spec.ts uses, because
* these browser capability gates are frequently unmet in the automated test browser regardless of
* the localRecording.disable config value.
*/
async function isLocalRecordingSupportedByBrowser(): Promise<boolean> {
return ctx.p1.execute(() => {
const browser = JitsiMeetJS.util.browser;
const PREFERRED_MEDIA_TYPE = 'video/webm;codecs=vp8,opus';
const isSupported = browser.isChromiumBased()
&& !browser.isElectron()
&& !browser.isReactNative()
&& !browser.isMobileDevice()
// @ts-ignore
&& Boolean(navigator.mediaDevices.setCaptureHandleConfig)
// @ts-ignore
&& typeof window.showSaveFilePicker !== 'undefined'
&& MediaRecorder.isTypeSupported(PREFERRED_MEDIA_TYPE);
let embeddedOk = true;
try {
if (window.self !== window.top) {
embeddedOk = window.self.location.host === window.parent.location.host;
}
} catch (e) {
embeddedOk = false;
}
return isSupported && embeddedOk;
});
}
/**
* Whether Dropbox is configured as a recording storage integration in this deployment
* (config.dropbox.appKey), independent of anything this file overrides. AbstractStartRecordingDialog
* treats it as a fallback recording service whenever recordingService.enabled is false (and prefers
* it over an empty selection, though local recording still wins over it when both are available) —
* so wherever "is some recording service available" is checked, Dropbox has to be accounted for too
* on any deployment where it happens to be configured, exactly like localRecordingSupport.byBrowser.
*/
async function isDropboxEnabledInDeployment(): Promise<boolean> {
return ctx.p1.execute(() => typeof config.dropbox?.appKey === 'string');
}
/**
* Fakes a "recording running" / "transcription running" state by dispatching the same low-level,
* side-effect-free redux actions the app's own middleware dispatches once a session is actually
* running, without going through the real start flow — which would either need a Jibri/Jigasi
* backend (cloud recording/transcription) or hit the browser's native showSaveFilePicker dialog
* (local recording), neither of which this environment can drive.
*
* - "recording running": SET_LOCAL_RECORDING_RUNNING only sets the reducer's localRecordingRunning
* flag (recording/middleware.ts has no side-effecting case for it, unlike START_LOCAL_RECORDING);
* isRecordingRunning() ORs it with cloud-recording state, so the dialog can't tell it apart from a
* real recording session.
* - "transcription running": isRecorderTranscriptionsRunning() requires BOTH
* features/transcribing.isTranscribing (set by TRANSCRIBER_JOINED/LEFT, a pure reducer
* transition — see transcribing/reducer.ts) AND the conference metadata's
* recording.isTranscribingEnabled flag (set via UPDATE_CONFERENCE_METADATA).
*/
async function setRunningState(recordingRunning: boolean, transcriptionRunning: boolean): Promise<void> {
await ctx.p1.execute((recording: boolean, transcription: boolean) => {
const { dispatch } = APP.store;
dispatch({ type: 'SET_LOCAL_RECORDING_RUNNING',
running: recording });
if (transcription) {
dispatch({ type: 'TRANSCRIBER_JOINED',
transcriberJID: 'fake-transcriber-for-testing' });
dispatch({
type: 'UPDATE_CONFERENCE_METADATA',
metadata: { recording: { isTranscribingEnabled: true } }
});
} else {
dispatch({ type: 'TRANSCRIBER_LEFT' });
dispatch({ type: 'UPDATE_CONFERENCE_METADATA',
metadata: null });
}
}, recordingRunning, transcriptionRunning);
}
setTestProperties(__filename, {
description: 'Recording & Transcription dialog rendering across config.js flag combinations',
usesBrowsers: [ 'p1' ]
});
/*
* Full 2^3 matrix over the three independently togglable config.js flags: recordingService.enabled,
* transcription.enabled and localRecording.disable. Expected behavior for each combination, derived
* from the source (AbstractStartRecordingDialog(Content), recording/functions.ts#getRecordButtonProps):
*
* - Every environment this suite runs against grants the local participant a default "recording"
* JWT feature claim regardless of authentication — confirmed via diagnostics logged below
* (localParticipantFeatures.recording is "true" even with no token/JWT at all). getRecordButtonProps()
* checks that claim *before* falling back to config-only visibility, so that fallback (which would
* let transcription.enabled alone drive visibility) is unreachable here: the toolbar button's
* visibility (and therefore whether the recording section can be inspected at all) reduces to
* recordingService.enabled || dropboxAvailable || localRecordingAvailable, same as
* recordingEnabled in getRecordButtonProps (which ORs in Dropbox too) — transcription.enabled never
* factors into it.
* - Whenever the dialog *can* open, the recording section renders — what changes is which storage
* service ends up selected by default: "Recording service" takes priority, then "Local recording",
* then "Dropbox" (see the constructor's own priority chain in AbstractStartRecordingDialog.ts).
* - The transcription section, its Start button, and the footer's "Start both" button are each
* governed purely by transcription.enabled (independent of the other two flags, since nothing is
* actually started in these tests) — but only reachable at all when the dialog can open in the
* first place, i.e. when some recording service is also available.
*
* The Live Streaming toolbar button is JWT-feature-gated with no config-only fallback (see
* LiveStream/AbstractLiveStreamButton.ts), so it can't be driven by config.js alone here — that's
* covered on the JaaS side instead, by tests/specs/jaas/recordingDialogConfig.spec.ts.
*
* localRecordingAvailable also depends on browser capabilities (MediaRecorder/File System Access
* support) that are frequently unmet in the automated test browser — probed once up front, exactly
* like recordingButtonVisibility.spec.ts does, so the per-combination expectations stay accurate
* regardless of what the test browser actually supports. dropboxAvailable is similarly probed once,
* since it depends on this deployment's config rather than anything this file overrides.
*/
describe('Recording dialog config matrix — recording × transcription × local recording', () => {
// A const-bound holder object, rather than a reassigned `let`, so the it() closures created
// inside the loop below don't trip @typescript-eslint/no-loop-func — only its properties (set
// once, in 'setup', before any of the loop's tests run) actually change.
const environment = { dropboxAvailable: false, localRecordingByBrowser: false };
it('setup', async () => {
await joinWithConfig({});
environment.localRecordingByBrowser = await isLocalRecordingSupportedByBrowser();
environment.dropboxAvailable = await isDropboxEnabledInDeployment();
});
const BOOLEANS = [ true, false ];
for (const recordingServiceEnabled of BOOLEANS) {
for (const transcriptionEnabled of BOOLEANS) {
for (const localRecordingDisabled of BOOLEANS) {
const title = `recordingService.enabled=${recordingServiceEnabled} `
+ `transcription.enabled=${transcriptionEnabled} `
+ `localRecording.disable=${localRecordingDisabled}`;
it(title, async () => {
await joinWithConfig({
recordingService: { enabled: recordingServiceEnabled },
transcription: { enabled: transcriptionEnabled },
localRecording: { disable: localRecordingDisabled }
});
const p1 = ctx.p1;
const localRecordingAvailable = !localRecordingDisabled && environment.localRecordingByBrowser;
const dialogCanOpen
= recordingServiceEnabled || localRecordingAvailable || environment.dropboxAvailable;
expect(await p1.getToolbar().hasRecordingButton()).toBe(dialogCanOpen);
if (!dialogCanOpen) {
// Nothing else is inspectable: the dialog cannot be opened at all.
return;
}
const dialog = p1.getRecordingTranscriptionDialog();
await p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
// The recording section always renders here (same condition as dialogCanOpen);
// which service ends up selected is what differs.
await dialog.toggleRecordingOptions();
const expectedService = recordingServiceEnabled ? 'Recording service'
: localRecordingAvailable ? 'Local recording'
: 'Dropbox';
expect(await dialog.getSelectedService()).toBe(expectedService);
expect(await dialog.hasTranscriptionOptions()).toBe(transcriptionEnabled);
expect(await dialog.hasStartTranscriptionButton()).toBe(transcriptionEnabled);
// The footer only makes sense when both sections are present; the recording
// section is always present on this branch, so this reduces to transcriptionEnabled.
expect(await dialog.hasStartBothButton()).toBe(transcriptionEnabled);
await dialog.cancel();
});
}
}
}
});
/*
* With both services enabled and available, the 2x2 matrix of which one is actually running.
* Expectations are read directly off web/StartRecordingDialogContent.tsx: each section swaps its
* own Start/Stop button independently, and the footer's _renderFooter() shows "Stop both" whenever
* ANY service is running and "Start both" whenever ANY service is NOT running — so in a mixed state
* (one running, one not) BOTH footer buttons render at once: "Stop both" stops the running one,
* "Start both" starts the missing one.
*/
describe('Recording dialog config — mixed running state (recording vs transcription)', () => {
const RUNNING_STATE_COMBINATIONS: Array<[boolean, boolean]> = [
[ false, false ],
[ true, false ],
[ false, true ],
[ true, true ]
];
for (const [ recordingRunning, transcriptionRunning ] of RUNNING_STATE_COMBINATIONS) {
it(`recording running=${recordingRunning}, transcription running=${transcriptionRunning}`, async () => {
await joinWithConfig({
recordingService: { enabled: true },
transcription: { enabled: true }
});
await setRunningState(recordingRunning, transcriptionRunning);
const dialog = ctx.p1.getRecordingTranscriptionDialog();
await ctx.p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
expect(await dialog.hasStartRecordingButton()).toBe(!recordingRunning);
expect(await dialog.hasStopRecordingButton()).toBe(recordingRunning);
expect(await dialog.hasStartTranscriptionButton()).toBe(!transcriptionRunning);
expect(await dialog.hasStopTranscriptionButton()).toBe(transcriptionRunning);
expect(await dialog.hasStopBothButton()).toBe(recordingRunning || transcriptionRunning);
expect(await dialog.hasStartBothButton()).toBe(!recordingRunning || !transcriptionRunning);
await dialog.cancel();
});
}
});
describe('Recording dialog config — recordingService.sharingEnabled', () => {
it('sharingEnabled: true shows the "share the recording link" switch', async () => {
await joinWithConfig({
recordingService: {
enabled: true,
sharingEnabled: true
}
});
const dialog = ctx.p1.getRecordingTranscriptionDialog();
await ctx.p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
await dialog.toggleRecordingOptions();
expect(await dialog.getSelectedService()).toBe('Recording service');
expect(await dialog.hasFileSharingSwitch()).toBe(true);
await dialog.cancel();
});
it('sharingEnabled: false hides the "share the recording link" switch', async () => {
await joinWithConfig({
recordingService: {
enabled: true,
sharingEnabled: false
}
});
const dialog = ctx.p1.getRecordingTranscriptionDialog();
await ctx.p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
await dialog.toggleRecordingOptions();
expect(await dialog.getSelectedService()).toBe('Recording service');
expect(await dialog.hasFileSharingSwitch()).toBe(false);
await dialog.cancel();
});
});
describe('Recording dialog config — localRecording.notifyAllParticipants', () => {
const NOTIFICATION_WARNING = 'will not be announced to other participants';
let localRecordingSupported: boolean;
it('setup', async () => {
await joinWithConfig({
localRecording: {
disable: false,
notifyAllParticipants: true
},
recordingService: { enabled: false }
});
localRecordingSupported = await isLocalRecordingSupportedByBrowser();
});
it('notifyAllParticipants: true — no "will not be announced" warning is shown', async () => {
if (!localRecordingSupported) {
return;
}
const dialog = ctx.p1.getRecordingTranscriptionDialog();
await ctx.p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
await dialog.toggleRecordingOptions();
expect(await dialog.getRecordingOptionsBodyText()).not.toContain(NOTIFICATION_WARNING);
await dialog.cancel();
});
it('notifyAllParticipants: false — the warning clears once "record only myself" is on', async () => {
if (!localRecordingSupported) {
return;
}
await joinWithConfig({
localRecording: {
disable: false,
notifyAllParticipants: false
},
recordingService: { enabled: false }
});
const dialog = ctx.p1.getRecordingTranscriptionDialog();
await ctx.p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
await dialog.toggleRecordingOptions();
expect(await dialog.getRecordingOptionsBodyText()).toContain(NOTIFICATION_WARNING);
await dialog.toggleLocalRecordingOnlySelfSwitch();
expect(await dialog.getRecordingOptionsBodyText()).not.toContain(NOTIFICATION_WARNING);
await dialog.cancel();
});
});

View File

@@ -0,0 +1,193 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { expectations } from '../../helpers/expectations';
import { ensureOneParticipant } from '../../helpers/participants';
setTestProperties(__filename, {
description: 'Unified Recording & Transcription dialog structure',
usesBrowsers: [ 'p1' ],
useJaas: true
});
describe('Recording & Transcription dialog', () => {
it('setup', async () => {
if (!expectations.jaas.recordingEnabled) {
ctx.skipSuiteTests = 'Recording is not enabled in this environment';
return;
}
if (!expectations.moderation.firstModerator) {
ctx.skipSuiteTests = 'First participant must be a moderator for these tests';
return;
}
await ensureOneParticipant();
expect(await ctx.p1.isModerator()).toBe(true);
});
it('dialog title is "Record & Transcribe"', async () => {
const p1 = ctx.p1;
const dialog = p1.getRecordingTranscriptionDialog();
await p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
expect(await dialog.getTitle()).toBe('Record & Transcribe');
await dialog.cancel();
});
it('both sections offer Start buttons when nothing is running', async () => {
const p1 = ctx.p1;
const dialog = p1.getRecordingTranscriptionDialog();
await p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
expect(await dialog.hasStartRecordingButton()).toBe(true);
expect(await dialog.hasStartTranscriptionButton()).toBe(true);
expect(await dialog.hasStopRecordingButton()).toBe(false);
expect(await dialog.hasStopTranscriptionButton()).toBe(false);
await dialog.cancel();
});
it('footer shows only "Start both" when nothing is running', async () => {
const p1 = ctx.p1;
const dialog = p1.getRecordingTranscriptionDialog();
await p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
expect(await dialog.hasStartBothButton()).toBe(true);
expect(await dialog.hasStopBothButton()).toBe(false);
await dialog.cancel();
});
it('start buttons are enabled when a recording service is available', async () => {
const p1 = ctx.p1;
const dialog = p1.getRecordingTranscriptionDialog();
await p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
// Diagnostics for intermittent failures here. The Start button's enabled state is driven
// by AbstractStartRecordingDialog's own local `this.state.selectedRecordingService` —
// that's plain React state, not redux, so APP.store.getState() can't see it.
// state['features/recording'].selectedRecordingService (read here previously) is a
// write-only mirror: it's only ever set by dispatch, when a user manually changes the
// dropdown via _onSelectedRecordingServiceChanged, and nothing reads it back into this
// component — so it reads '' in this test regardless of the real bug, which is exactly
// why it looked identical across every prior failure and told us nothing. Read the
// dropdown's own rendered text instead: it's rendered straight from the real local state.
await dialog.toggleRecordingOptions();
const diagnostics = await p1.execute(() => ({
recordingServiceConfig: config.recordingService,
localRecordingConfig: config.localRecording,
reduxRecordingServiceConfig: APP.store.getState()['features/base/config'].recordingService,
reduxLocalRecordingConfig: APP.store.getState()['features/base/config'].localRecording,
hasDropboxToken: Boolean(APP.store.getState()['features/dropbox'].token)
}));
const renderedSelectedService = await dialog.getSelectedService();
await p1.log(`Recording dialog diagnostics: ${JSON.stringify({ ...diagnostics, renderedSelectedService })}`);
expect(await dialog.isStartRecordingEnabled()).toBe(true);
expect(await dialog.isStartBothEnabled()).toBe(true);
await dialog.cancel();
});
it('options accordions are collapsed by default', async () => {
const p1 = ctx.p1;
const dialog = p1.getRecordingTranscriptionDialog();
await p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
expect(await dialog.hasRecordingOptions()).toBe(true);
expect(await dialog.hasTranscriptionOptions()).toBe(true);
expect(await dialog.isRecordingOptionsExpanded()).toBe(false);
expect(await dialog.isTranscriptionOptionsExpanded()).toBe(false);
// The dropdowns are only rendered once the accordions are expanded.
expect(await dialog.hasServiceSelect()).toBe(false);
expect(await dialog.hasLanguageSelect()).toBe(false);
await dialog.cancel();
});
it('expanding recording options reveals the storage service dropdown', async () => {
const p1 = ctx.p1;
const dialog = p1.getRecordingTranscriptionDialog();
await p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
await dialog.toggleRecordingOptions();
expect(await dialog.isRecordingOptionsExpanded()).toBe(true);
expect(await dialog.hasServiceSelect()).toBe(true);
// A service is preselected — the trigger shows its name.
expect((await dialog.getSelectedService()).length).toBeGreaterThan(0);
// Collapsing hides the options again.
await dialog.toggleRecordingOptions();
expect(await dialog.isRecordingOptionsExpanded()).toBe(false);
expect(await dialog.hasServiceSelect()).toBe(false);
await dialog.cancel();
});
it('expanding transcription options allows changing the language', async () => {
const p1 = ctx.p1;
const dialog = p1.getRecordingTranscriptionDialog();
await p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
await dialog.toggleTranscriptionOptions();
expect(await dialog.isTranscriptionOptionsExpanded()).toBe(true);
expect(await dialog.hasLanguageSelect()).toBe(true);
await dialog.selectLanguage('French');
expect(await dialog.getSelectedLanguage()).toBe('French');
await dialog.selectLanguage('English');
expect(await dialog.getSelectedLanguage()).toBe('English');
await dialog.cancel();
});
it('cancel closes the dialog without errors', async () => {
const p1 = ctx.p1;
const dialog = p1.getRecordingTranscriptionDialog();
await p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
await dialog.cancel();
await p1.driver.$(
'#dialog-title'
).waitForExist({ reverse: true, timeout: 3000, timeoutMsg: 'Dialog did not close after cancel' });
});
it('dialog reopens cleanly after cancel — button is still in toolbar', async () => {
const p1 = ctx.p1;
// Verify the button is still accessible and the dialog can be opened again.
expect(await p1.getToolbar().hasRecordingButton()).toBe(true);
const dialog = p1.getRecordingTranscriptionDialog();
await p1.getToolbar().clickRecordingButton();
await dialog.waitForDisplay();
expect(await dialog.getTitle()).toBe('Record & Transcribe');
await dialog.cancel();
});
});

View File

@@ -0,0 +1,37 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant, ensureTwoParticipants } from '../../helpers/participants';
const MY_TEST_SUBJECT = 'My Test Subject';
const SUBJECT_XPATH = '//div[starts-with(@class, "subject-text")]';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Subject', () => {
it('setup', async () => {
await ensureOneParticipant({
configOverwrite: {
subject: MY_TEST_SUBJECT
}
});
await ensureTwoParticipants();
});
it('subject set locally', async () => await checkSubject(ctx.p1, MY_TEST_SUBJECT));
it('subject set remotely', async () => await checkSubject(ctx.p2, MY_TEST_SUBJECT));
});
/**
* Check was subject set.
*
* @param participant
* @param subject
*/
async function checkSubject(participant: Participant, subject: string) {
const localTile = participant.driver.$(SUBJECT_XPATH);
await localTile.moveTo();
expect(await localTile.getText()).toBe(subject);
}

View File

@@ -0,0 +1,125 @@
import { expect } from '@wdio/globals';
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
const PILL_SELECTOR = '[data-testid="time-timer-pill"]';
// The shape mod_time_restricted broadcasts. A participant that could get this
// honoured would be able to put an arbitrary countdown on everyone's screen.
//
// The duration is deliberately unlike anything a deployment would enforce, so
// it identifies the forgery: a target running mod_time_restricted pushes its
// own (much shorter) limit on join, and these tests must tell the two apart
// rather than assume no timer is running at all.
const FORGED_DURATION = 1800;
const PAYLOAD = {
type: 'time_restricted',
durationSeconds: FORGED_DURATION,
elapsedSeconds: 0
};
// JitsiConferenceEvents.NON_PARTICIPANT_MESSAGE_RECEIVED. Spelled out because
// the enum is not reachable from the page context.
const NON_PARTICIPANT_MESSAGE_RECEIVED = 'conference.non_participant_message_received';
/**
* The duration of the timer currently running for this participant, or 0 when
* none is. Read from redux rather than the pill: the pill is additionally
* gated by the suppression window, and the duration is what tells a forged
* timer apart from a legitimate server-pushed one.
*
* @param {Participant} p - The participant.
* @returns {Promise<number>}
*/
function timerDuration(p: Participant): Promise<number> {
return p.execute(() => APP.store.getState()['features/time-timer'].durationSeconds);
}
/**
* Delivers a NON_PARTICIPANT_MESSAGE_RECEIVED straight into the conference's
* event emitter, which is the seam the time-timer middleware listens on. `id`
* is the sender's endpoint id — null only when lib-jitsi-meet could not
* attribute the stanza to an occupant, i.e. when the server itself sent it.
*
* @param {Participant} p - The participant to deliver to.
* @param {string | null} id - The sender endpoint id to report.
* @returns {Promise<void>}
*/
function injectNonParticipantMessage(p: Participant, id: string | null): Promise<void> {
return p.execute((eventName: string, senderId: string | null, json: any) => {
APP.conference._room.eventEmitter.emit(eventName, senderId, json);
}, NON_PARTICIPANT_MESSAGE_RECEIVED, id, PAYLOAD);
}
describe('Time timer server message', () => {
it('joining the meeting', async () => {
// The default test config disables the timer, so enable it explicitly.
// The suppression window is pinned so the pill assertion at the end is
// about the timer and not about a deployment's display policy.
await ensureTwoParticipants({
configOverwrite: {
timeTimer: {
enabled: true,
suppressForSeconds: 0
}
}
});
// No assertion on whether a timer is already running: a target with
// mod_time_restricted enabled pushes its limit to every occupant on
// join, and one without it leaves the timer idle. Both are fine — what
// the tests below check is that the forged duration is never adopted.
expect(await timerDuration(ctx.p1)).not.toBe(FORGED_DURATION);
});
it('ignores the payload sent as a json-message by another participant', async () => {
const { p1, p2 } = ctx;
// The realistic attack: p2 puts the exact payload into the room. The MUC
// stamps p2's occupant JID on it, so p1 sees it as p2's message and must
// not adopt the duration it carries.
await p2.execute(payload => {
APP.conference._room.room.sendMessage(JSON.stringify(payload), 'json-message');
}, PAYLOAD);
await p1.driver.pause(2000);
expect(await timerDuration(p1)).not.toBe(FORGED_DURATION);
});
it('ignores a non-participant message that carries a sender id', async () => {
const { p1 } = ctx;
// A sender we cannot resolve to a participant — a hidden occupant, or one
// whose presence has not been processed yet. It still has a nick, so the
// message is attributable to somebody and must not be trusted.
await injectNonParticipantMessage(p1, 'abc123');
await p1.driver.pause(2000);
expect(await timerDuration(p1)).not.toBe(FORGED_DURATION);
});
it('starts the timer for a message with no sender id', async () => {
const { p1 } = ctx;
// The server-originated case: sent from the bare room JID, so there is no
// resource to derive an id from. This is the only shape that is honoured —
// and it proves the two cases above are rejected on the sender id, not
// because the payload or the wiring is broken.
await injectNonParticipantMessage(p1, null);
await p1.driver.waitUntil(async () => await timerDuration(p1) === FORGED_DURATION, {
timeout: 3000,
timeoutMsg: 'a server-originated time_restricted message did not start the timer'
});
await expect(await p1.driver.$(PILL_SELECTOR)).toBeDisplayed();
});
});

View File

@@ -0,0 +1,84 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { expectations } from '../../helpers/expectations';
import { joinMuc } from '../../helpers/joinMuc';
setTestProperties(__filename, {
usesBrowsers: [ 'p1' ]
});
describe('URL normalisation', () => {
// If we're not able to create conferences with a custom tenant, we'll only test the room name.
const useTenant = expectations.useTenant;
const tests = [
{
hint: '@ sign and .',
// Room as entered in the URL
room: '@example.com',
// Room as normalized in the URL
roomUrl: 'example.com',
// The room part of the MUC JID
roomJid: 'example.com',
// Tenant as entered in the URL
tenant: 'tenant@example.com',
// Tenant as normalized in the URL
tenantUrl: 'tenantexample.com',
// The tenant part of the MUC JID
tenantJid: 'tenantexample_com'
},
{
hint: 'Dashes',
room: 'foo-bar',
roomUrl: 'foo-bar',
roomJid: 'foo-bar',
tenant: 'tenant-example.com',
tenantUrl: 'tenant-example.com',
tenantJid: 'tenant-example_com'
},
{
hint: 'Cyrillic',
room: 'фоо-бар',
roomUrl: '%D1%84%D0%BE%D0%BE-%D0%B1%D0%B0%D1%80',
roomJid: '%d1%84%d0%be%d0%be-%d0%b1%d0%b0%d1%80',
tenant: 'обитател',
tenantUrl: '%D0%BE%D0%B1%D0%B8%D1%82%D0%B0%D1%82%D0%B5%D0%BB',
tenantJid: '%d0%be%d0%b1%d0%b8%d1%82%d0%b0%d1%82%d0%b5%d0%bb',
}
];
for (const test of tests) {
it(test.hint, async () => {
const fullRoom = `${test.room}${ctx.roomName}`;
const fullRoomUrl = `${test.roomUrl}${ctx.roomName}`;
const tenant = useTenant ? test.tenant : undefined;
const p = await joinMuc({
name: 'p1',
token: testsConfig.jwt.preconfiguredToken,
}, {
tenant: tenant,
roomName: fullRoom
});
const currentUrlStr = await p.driver.getUrl();
const currentUrl = new URL(currentUrlStr);
const path = currentUrl.pathname;
const parts = path.split('/');
if (useTenant) {
expect(parts[1]).toBe(test.tenantUrl);
}
expect(parts[2]).toBe(fullRoomUrl);
const mucJid = (await p.execute(() => APP.conference._room.room.roomjid)).split('@');
const roomJid = mucJid[0];
const domain = mucJid[1];
expect(roomJid).toBe(`${test.roomJid}${ctx.roomName}`);
if (useTenant) {
expect(domain.startsWith(`conference.${test.tenantJid}.`)).toBe(true);
}
});
}
});

View File

@@ -0,0 +1,225 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1' ]
});
/**
* Reads the current virtual-background slice of the Redux store.
*/
async function getVBState() {
return ctx.p1.execute(() => APP.store.getState()['features/virtual-background']);
}
/**
* Opens the virtual background dialog via the toolbar button and waits for it to be displayed.
*/
async function openVBDialog() {
await ctx.p1.getToolbar().clickVirtualBackgroundButton();
await ctx.p1.getVirtualBackgroundDialog().waitForDisplay();
}
/**
* Waits until backgroundEffectEnabled settles to the expected value.
* Needed because jitsiTrack.setEffect() is async — an effect load failure can briefly flip the
* flag to true before rolling it back to false.
* When expecting true, the value must remain stable for stabilityWindow ms to avoid passing on
* the optimistic pre-apply dispatch.
*
* @param {boolean} expected - The expected settled value.
* @param {number} timeout - How long to wait in ms.
* @param {number} stabilityWindow - How long the expected value must remain unchanged (ms).
*/
async function waitForEffectEnabled(expected: boolean, timeout = 8000, stabilityWindow = 500): Promise<void> {
let matchedAt: number | undefined;
const requiredStableMs = expected ? stabilityWindow : 0;
await ctx.p1.driver.waitUntil(
async () => {
const state = await getVBState();
if (state.backgroundEffectEnabled !== expected) {
matchedAt = undefined;
return false;
}
if (requiredStableMs === 0) {
return true;
}
const now = Date.now();
if (matchedAt === undefined) {
matchedAt = now;
return false;
}
return now - matchedAt >= requiredStableMs;
},
{ timeout, timeoutMsg: `backgroundEffectEnabled did not settle to ${expected} within ${timeout}ms` }
);
}
describe('Virtual backgrounds', () => {
it('joining the meeting', async () => {
await ensureOneParticipant({
configOverwrite: {
disableVirtualBackground: false
}
});
});
it('open virtual background dialog', async () => {
await openVBDialog();
expect(await ctx.p1.getVirtualBackgroundDialog().isDisplayed()).toBe(true);
});
it('select none — aria-checked and redux state', async () => {
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickNone();
expect(await vbDialog.isNoneChecked()).toBe(true);
expect(await vbDialog.isBlurChecked()).toBe(false);
expect(await vbDialog.isSlightBlurChecked()).toBe(false);
await vbDialog.confirm();
await waitForEffectEnabled(false);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(false);
expect(state.selectedThumbnail).toBe('none');
});
it('select slight blur — aria-checked and redux state', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickSlightBlur();
expect(await vbDialog.isSlightBlurChecked()).toBe(true);
expect(await vbDialog.isNoneChecked()).toBe(false);
expect(await vbDialog.isBlurChecked()).toBe(false);
await vbDialog.confirm();
// This is the first real (non-preview) effect enable in the suite, so — like
// "select full blur" below — it pays the cold-start cost of spinning up the
// segmentation worker on the actual conference track. The default 8s timeout is
// tuned for a warm worker and flakes on this one.
await waitForEffectEnabled(true, 15000);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
expect(state.blurValue).toBe(8);
expect(state.selectedThumbnail).toBe('slight-blur');
});
it('select full blur — aria-checked and redux state', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickBlur();
expect(await vbDialog.isBlurChecked()).toBe(true);
expect(await vbDialog.isNoneChecked()).toBe(false);
expect(await vbDialog.isSlightBlurChecked()).toBe(false);
await vbDialog.confirm();
// Wait long enough for the async setEffect() to complete without rolling back.
// A false value here would mean the effect threw and backgroundEffectEnabled was reset.
await waitForEffectEnabled(true, 15000);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
expect(state.blurValue).toBe(25);
expect(state.selectedThumbnail).toBe('blur');
});
it('disable background — redux state correct', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickNone();
await vbDialog.confirm();
await waitForEffectEnabled(false);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(false);
expect(state.selectedThumbnail).toBe('none');
});
it('rapid switching before confirm — only last selection applied', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
// Click through several blur options quickly without confirming between them
await vbDialog.clickBlur();
await vbDialog.clickNone();
await vbDialog.clickSlightBlur();
await vbDialog.clickBlur();
await vbDialog.clickSlightBlur();
// Slight blur should be the active selection in the dialog
expect(await vbDialog.isSlightBlurChecked()).toBe(true);
expect(await vbDialog.isBlurChecked()).toBe(false);
expect(await vbDialog.isNoneChecked()).toBe(false);
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.selectedThumbnail).toBe('slight-blur');
expect(state.backgroundType).toBe('blur');
expect(state.blurValue).toBe(8);
});
it('cancel discards selection — redux state unchanged', async () => {
// Precondition: slight-blur is applied from the previous test
const stateBefore = await getVBState();
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickBlur();
expect(await vbDialog.isBlurChecked()).toBe(true);
await vbDialog.cancel();
const stateAfter = await getVBState();
expect(stateAfter.selectedThumbnail).toBe(stateBefore.selectedThumbnail);
expect(stateAfter.backgroundEffectEnabled).toBe(stateBefore.backgroundEffectEnabled);
expect(stateAfter.backgroundType).toBe(stateBefore.backgroundType);
});
it('persistence across reopen — previously applied thumbnail shown as selected', async () => {
// Apply full blur
await openVBDialog();
await ctx.p1.getVirtualBackgroundDialog().clickBlur();
await ctx.p1.getVirtualBackgroundDialog().confirm();
await waitForEffectEnabled(true, 15000);
// Reopen and verify blur is still aria-checked
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
expect(await vbDialog.isBlurChecked()).toBe(true);
expect(await vbDialog.isSlightBlurChecked()).toBe(false);
expect(await vbDialog.isNoneChecked()).toBe(false);
// Clean up
await vbDialog.clickNone();
await vbDialog.confirm();
await waitForEffectEnabled(false);
});
});

View File

@@ -0,0 +1,336 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant, hangupAllParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1' ]
});
const V2_CONFIG = {
disableVirtualBackground: false,
virtualBackground: {
enableV2: true
}
};
/**
* Reads the current virtual-background slice of the Redux store.
*/
async function getVBState() {
return ctx.p1.execute(() => APP.store.getState()['features/virtual-background']);
}
/**
* Reads the virtualBackground config from the Redux store.
*/
async function getVBConfig() {
return ctx.p1.execute(
() => APP.store.getState()['features/base/config'].virtualBackground);
}
/**
* Opens the virtual background dialog via the toolbar button and waits for it to be displayed.
*/
async function openVBDialog() {
await ctx.p1.getToolbar().clickVirtualBackgroundButton();
await ctx.p1.getVirtualBackgroundDialog().waitForDisplay();
}
/**
* Reads the V2 effect's processor state from the local video JitsiLocalTrack.
* Returns `{ isReady, frameCount }` or `null` when the effect or processor is absent.
*
* Used to test the mute/unmute fix directly without depending on pixel rendering of the
* stage or filmstrip thumbnail — both of which are unreliable in headless Chrome.
*/
async function getV2ProcessorState(): Promise<{ frameCount: number; isReady: boolean; } | null> {
return ctx.p1.execute(() => {
const tracks = APP.store.getState()['features/base/tracks'];
const localVideo = tracks.find((t: any) => t.local && t.mediaType === 'video');
const processor = (localVideo?.jitsiTrack as any)?._streamEffect?._processor;
if (!processor) {
return null;
}
return {
frameCount: processor._frameCount ?? 0,
isReady: Boolean(processor._isReady)
};
});
}
/**
* Waits until the V2 effect's inference worker has finished initialising. Redux flips
* backgroundEffectEnabled to true the moment setEffect dispatches — well before the worker
* has loaded the segmentation model. Tests that immediately mute/unmute after waiting only
* on the Redux flag race against the worker init, intermittently failing on slower runners.
*
* @param {number} timeout - How long to wait in ms.
*/
async function waitForV2EffectReady(timeout = 15000): Promise<void> {
await ctx.p1.driver.waitUntil(
async () => Boolean((await getV2ProcessorState())?.isReady),
{ timeout, timeoutMsg: `V2 effect processor not ready within ${timeout}ms` }
);
}
/**
* Waits until backgroundEffectEnabled settles to the expected value.
* When expecting true, the value must remain stable for stabilityWindow ms to confirm the
* async setEffect() did not roll back.
*
* @param {boolean} expected - The expected settled value.
* @param {number} timeout - How long to wait in ms.
* @param {number} stabilityWindow - How long the expected value must remain unchanged (ms).
*/
async function waitForEffectEnabled(expected: boolean, timeout = 15000, stabilityWindow = 500): Promise<void> {
let matchedAt: number | undefined;
const requiredStableMs = expected ? stabilityWindow : 0;
await ctx.p1.driver.waitUntil(
async () => {
const state = await getVBState();
if (state.backgroundEffectEnabled !== expected) {
matchedAt = undefined;
return false;
}
if (requiredStableMs === 0) {
return true;
}
const now = Date.now();
if (matchedAt === undefined) {
matchedAt = now;
return false;
}
return now - matchedAt >= requiredStableMs;
},
{ timeout, timeoutMsg: `backgroundEffectEnabled did not settle to ${expected} within ${timeout}ms` }
);
}
describe('Virtual backgrounds V2 engine', () => {
it('joining the meeting with V2 enabled', async () => {
await ensureOneParticipant({
configOverwrite: V2_CONFIG
});
// Verify V2 config is active in the Redux store.
const vbConfig = await getVBConfig();
expect(vbConfig?.enableV2).toBe(true);
});
it('select blur with V2 engine — effect enables and redux state correct', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickBlur();
expect(await vbDialog.isBlurChecked()).toBe(true);
await vbDialog.confirm();
await waitForEffectEnabled(true);
// Worker model load is async; the next test mute/unmutes immediately after, so wait
// for the processor to actually be ready before letting the suite proceed.
await waitForV2EffectReady();
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
expect(state.blurValue).toBe(25);
expect(state.selectedThumbnail).toBe('blur');
});
it('mute and unmute camera while blur is active — processor stays ready across cycle', async () => {
// Blur was enabled by the prior test (which also waited for the processor to be ready).
let state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
const baseline = await getV2ProcessorState();
expect(baseline).not.toBeNull();
expect(baseline!.isReady).toBe(true);
// Mute then unmute. Pre-fix this disposed the processor (_isReady -> false) and unmute
// had to reload the model (~1-2s). Post-fix the processor stays alive throughout.
// Hold mute for 2s so that the mute fully settles before unmuting.
await ctx.p1.getToolbar().clickVideoMuteButton();
await ctx.p1.driver.pause(2000);
await ctx.p1.getToolbar().clickVideoUnmuteButton();
await ctx.p1.driver.pause(1000);
// Immediately after unmute the processor must still be ready (no dispose on mute).
// If the dispose-on-stopEffect regression returned, _isReady would be false for the
// ~1-2s it takes to reload the worker model.
const afterUnmute = await getV2ProcessorState();
expect(afterUnmute).not.toBeNull();
expect(afterUnmute!.isReady).toBe(true);
// Effect state should remain consistent.
state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
});
it('select slight blur with V2 engine — redux state correct', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickSlightBlur();
expect(await vbDialog.isSlightBlurChecked()).toBe(true);
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
expect(state.blurValue).toBe(8);
expect(state.selectedThumbnail).toBe('slight-blur');
});
it('disable V2 background — redux state resets', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickNone();
await vbDialog.confirm();
await waitForEffectEnabled(false);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(false);
expect(state.selectedThumbnail).toBe('none');
});
});
describe('Virtual backgrounds V2 with tier override', () => {
it('hangup and rejoin with tier override config', async () => {
await hangupAllParticipants();
});
it('joining with V2 + low tier override', async () => {
await ensureOneParticipant({
configOverwrite: {
...V2_CONFIG,
virtualBackground: {
...V2_CONFIG.virtualBackground,
advanced: {
tierOverride: 'low'
}
}
}
});
const vbConfig = await getVBConfig();
expect(vbConfig?.enableV2).toBe(true);
expect(vbConfig?.advanced?.tierOverride).toBe('low');
});
it('blur works on low tier', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickBlur();
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
expect(state.blurValue).toBe(25);
});
it('disable effect on low tier — clean shutdown', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickNone();
await vbDialog.confirm();
await waitForEffectEnabled(false);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(false);
});
});
describe('Virtual backgrounds V2 with insertable streams disabled', () => {
it('hangup and rejoin with captureStream config', async () => {
await hangupAllParticipants();
});
it('joining with V2 + captureStream fallback', async () => {
await ensureOneParticipant({
configOverwrite: {
...V2_CONFIG,
virtualBackground: {
...V2_CONFIG.virtualBackground,
advanced: {
useInsertableStreams: false
}
}
}
});
const vbConfig = await getVBConfig();
expect(vbConfig?.enableV2).toBe(true);
expect(vbConfig?.advanced?.useInsertableStreams).toBe(false);
});
it('blur works on captureStream path', async () => {
await openVBDialog();
const vbDialog = ctx.p1.getVirtualBackgroundDialog();
await vbDialog.clickBlur();
await vbDialog.confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
expect(state.blurValue).toBe(25);
});
it('disable and re-enable on captureStream path', async () => {
// Disable.
await openVBDialog();
await ctx.p1.getVirtualBackgroundDialog().clickNone();
await ctx.p1.getVirtualBackgroundDialog().confirm();
await waitForEffectEnabled(false);
// Re-enable with slight blur.
await openVBDialog();
await ctx.p1.getVirtualBackgroundDialog().clickSlightBlur();
await ctx.p1.getVirtualBackgroundDialog().confirm();
await waitForEffectEnabled(true);
const state = await getVBState();
expect(state.backgroundEffectEnabled).toBe(true);
expect(state.backgroundType).toBe('blur');
expect(state.blurValue).toBe(8);
// Clean up.
await openVBDialog();
await ctx.p1.getVirtualBackgroundDialog().clickNone();
await ctx.p1.getVirtualBackgroundDialog().confirm();
await waitForEffectEnabled(false);
});
});

View File

@@ -0,0 +1,36 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant } from '../../helpers/participants';
setTestProperties(__filename, {
description: 'Test that conference requests work over XMPP',
usesBrowsers: [ 'p1' ]
});
describe('XMPP Conference Request', () => {
it('join with conferenceRequestUrl disabled', async () => {
await ensureOneParticipant({
skipWaitToJoin: true,
skipPrejoinButtonClick: true,
configOverwrite: {
prejoinConfig: {
enabled: true
}
}
});
const { p1 } = ctx;
// Update config before joining, because this option cannot be overridden with URL params.
await p1.driver.execute(async () => {
config.conferenceRequestUrl = '';
APP.store.dispatch({
type: 'OVERWRITE_CONFIG',
config
});
});
await p1.getPreJoinScreen().getJoinButton().click();
await p1.waitForMucJoinedOrError();
expect(await p1.isInMuc()).toBe(true);
});
});

View File

@@ -0,0 +1,56 @@
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { expectations } from '../../helpers/expectations';
import { ensureOneParticipant, ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
description: 'This test checks the ability of a moderator to grant the "moderator" role to another participant. The\
test is skipped when the "allModerators" expectation is set.',
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Grant moderator', () => {
let p1: Participant, p2: Participant;
it('setup', async () => {
if (expectations.moderation.allModerators) {
ctx.skipSuiteTests = 'allModerators is expected';
return;
}
await ensureOneParticipant();
p1 = ctx.p1;
expect(await p1.isModerator()).toBe(true);
const grantOwnerType = await p1.execute(() => typeof APP.conference._room.grantOwner);
const functionAvailable = grantOwnerType === 'function';
if (expectations.moderation.grantModerator) {
expect(grantOwnerType).toBe('function');
} else {
if (!functionAvailable) {
ctx.skipSuiteTests = 'grantModerator is not available and not expected';
return;
}
}
await ensureTwoParticipants();
p2 = ctx.p2;
expect(await p2.isModerator()).toBe(false);
});
it('grant moderator', async () => {
await p1.getFilmstrip().grantModerator(p2);
await p2.driver.waitUntil(
() => p2.isModerator(),
{
timeout: 3000,
timeoutMsg: 'p2 did not become moderator'
}
);
});
});

View File

@@ -0,0 +1,76 @@
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { expectations } from '../../helpers/expectations';
import { ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
retry: true,
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Kick', () => {
let p1: Participant, p2: Participant;
it('setup', async () => {
await ensureTwoParticipants();
p1 = ctx.p1;
p2 = ctx.p2;
// We verify elsewhere (moderation.spec.ts) that the first participant is a moderator.
if (!await p1.isModerator()) {
ctx.skipSuiteTests = 'the first participant is not a moderator';
}
});
it('kick (p2p disabled)', () => kickAndCheck(p1, p2));
it('setup (p2p enabled)', async () => {
await p1.hangup();
await p2.hangup();
await ensureTwoParticipants({
configOverwrite: {
p2p: {
enabled: true
}
}
});
p1 = ctx.p1;
p2 = ctx.p2;
});
it('kick (p2p enabled)', async () => {
await kickAndCheck(p1, p2);
});
it('non-moderator cannot kick', async () => {
if (!expectations.moderation.allModerators) {
await ensureTwoParticipants();
p2 = ctx.p2;
expect(await p2.isModerator()).toBe(false);
await p2.execute(
epId => APP.conference._room.kickParticipant(epId, 'for funzies'),
await p1.getEndpointId()
);
await p1.driver.pause(3000);
expect(await p1.isInMuc()).toBe(true);
}
});
});
/**
* Kicks the second participant and checks that the participant is removed from the conference and that dialogue is open.
*/
async function kickAndCheck(kicker: Participant, kickee: Participant) {
await kicker.getFilmstrip().kickParticipant(await kickee.getEndpointId());
await kicker.waitForParticipants(0);
// check that the kicked participant sees the kick reason dialog
await kickee.driver.waitUntil(
async () => kickee.isLeaveReasonDialogOpen(), {
timeout: 2000,
timeoutMsg: 'No leave reason dialog shown for p2'
});
}

View File

@@ -0,0 +1,512 @@
import { P1, P3, Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { expectations } from '../../helpers/expectations';
import {
ensureOneParticipant,
ensureThreeParticipants,
ensureTwoParticipants,
hangupAllParticipants
} from '../../helpers/participants';
import type { IJoinOptions } from '../../helpers/types';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
describe('Lobby', () => {
it('joining the meeting', async () => {
await ensureOneParticipant();
if (!await ctx.p1.execute(() => APP.conference._room.isLobbySupported())) {
ctx.skipSuiteTests = 'The environment does not support lobby.';
}
});
it('enable', async () => {
await ensureTwoParticipants();
await enableLobby();
});
it('entering in lobby and approve', async () => {
const { p1, p2 } = ctx;
await enterLobby(p1, true);
const { p3 } = ctx;
await p1.getNotifications().allowLobbyParticipant();
const notificationText = await p2.getNotifications().getLobbyParticipantAccessGranted();
expect(notificationText).toContain(P1);
expect(notificationText).toContain(P3);
await p2.getNotifications().closeLobbyParticipantAccessGranted();
// ensure 3 participants in the call will check for the third one that muc is joined, ice connected,
// media is being receiving and there are two remote streams
await p3.waitToJoinMUC();
await p3.waitForIceConnected();
await p3.waitForReceiveMedia();
await p3.waitForRemoteStreams(2);
// now check third one display name in the room, is the one set in the prejoin screen
const name = await p1.getFilmstrip().getRemoteDisplayName(await p3.getEndpointId());
expect(name).toBe(P3);
await p3.hangup();
});
it('entering in lobby and deny', async () => {
const { p1, p2 } = ctx;
// the first time tests is executed we need to enter display name,
// for next execution that will be locally stored
await enterLobby(p1, false);
// moderator rejects access
await p1.getNotifications().rejectLobbyParticipant();
// deny notification on 2nd participant
const notificationText = await p2.getNotifications().getLobbyParticipantAccessDenied();
expect(notificationText).toContain(P1);
expect(notificationText).toContain(P3);
await p2.getNotifications().closeLobbyParticipantAccessDenied();
const { p3 } = ctx;
// check the denied one is out of lobby, sees the notification about it
await p3.getNotifications().waitForLobbyAccessDeniedNotification();
expect(await p3.getLobbyScreen().isLobbyRoomJoined()).toBe(false);
await p3.hangup();
});
it('approve from participants pane', async () => {
const { p1 } = ctx;
const knockingParticipant = await enterLobby(p1, false);
// moderator allows access
const p1ParticipantsPane = p1.getParticipantsPane();
await p1ParticipantsPane.open();
await p1ParticipantsPane.admitLobbyParticipant(knockingParticipant);
await p1ParticipantsPane.close();
const { p3 } = ctx;
// ensure 3 participants in the call will check for the third one that muc is joined, ice connected,
// media is being receiving and there are two remote streams
await p3.waitToJoinMUC();
await p3.waitForIceConnected();
await p3.waitForReceiveMedia();
await p3.waitForRemoteStreams(2);
// now check third one display name in the room, is the one set in the prejoin screen
const name = await p1.getFilmstrip().getRemoteDisplayName(await p3.getEndpointId());
expect(name).toBe(P3);
await p3.hangup();
});
it('reject from participants pane', async () => {
const { p1 } = ctx;
const knockingParticipant = await enterLobby(p1, false);
// moderator rejects access
const p1ParticipantsPane = p1.getParticipantsPane();
await p1ParticipantsPane.open();
await p1ParticipantsPane.rejectLobbyParticipant(knockingParticipant);
await p1ParticipantsPane.close();
const { p3 } = ctx;
// check the denied one is out of lobby, sees the notification about it
// The third participant should see a warning that his access to the room was denied
await p3.getNotifications().waitForLobbyAccessDeniedNotification();
// check Lobby room not left
expect(await p3.getLobbyScreen().isLobbyRoomJoined()).toBe(false);
await p3.hangup();
});
it('lobby user leave', async () => {
const { p1 } = ctx;
await enterLobby(p1, false);
await ctx.p3.hangup();
// check that moderator (participant 1) no longer sees notification about participant in lobby
await p1.getNotifications().waitForHideOfKnockingParticipants();
});
it('lobby persistence', async () => {
const { p1, p2 } = ctx;
await enterLobby(p1, false);
const { p3 } = ctx;
expect(await p3.getLobbyScreen().isLobbyRoomJoined()).toBe(true);
await p1.hangup();
await p2.hangup();
await p3.driver.$('.dialog.leaveReason').isExisting();
await p3.driver.waitUntil(
async () => !await p3.getLobbyScreen().isLobbyRoomJoined(),
{
timeout: 2000,
timeoutMsg: 'p3 did not leave lobby'
}
);
await p3.hangup();
});
it('disable while participant in lobby', async () => {
await ensureTwoParticipants();
const { p1 } = ctx;
await enableLobby();
await enterLobby(p1);
const p1SecurityDialog = p1.getSecurityDialog();
await p1.getToolbar().clickSecurityButton();
await p1SecurityDialog.waitForDisplay();
await p1SecurityDialog.toggleLobby();
await p1SecurityDialog.waitForLobbyEnabled(true);
const { p3 } = ctx;
await p3.waitToJoinMUC();
expect(await p3.getLobbyScreen().isLobbyRoomJoined()).toBe(false);
});
it('change of moderators in lobby', async () => {
// The test below is only correct when the environment is configured to automatically elect a new moderator
// when the moderator leaves. For environments where this is not the case, the test is skipped.
if (!expectations.autoModerator) {
return;
}
await hangupAllParticipants();
await ensureTwoParticipants();
const { p1, p2 } = ctx;
// hanging up the first one, which is moderator and second one should be
await p1.hangup();
await p2.driver.waitUntil(
() => p2.isModerator(),
{
timeout: 3000,
timeoutMsg: 'p2 is not moderator after p1 leaves'
}
);
const p2SecurityDialog = p2.getSecurityDialog();
await p2.getToolbar().clickSecurityButton();
await p2SecurityDialog.waitForDisplay();
await p2SecurityDialog.toggleLobby();
await p2SecurityDialog.waitForLobbyEnabled();
// here the important check is whether the moderator sees the knocking participant
await enterLobby(p2, false);
});
it('shared password', async () => {
await hangupAllParticipants();
await ensureTwoParticipants();
const { p1 } = ctx;
await enableLobby();
const p1SecurityDialog = p1.getSecurityDialog();
await p1.getToolbar().clickSecurityButton();
await p1SecurityDialog.waitForDisplay();
expect(await p1SecurityDialog.isLocked()).toBe(false);
const roomPasscode = String(Math.trunc(Math.random() * 1_000_000));
await p1SecurityDialog.addPassword(roomPasscode);
await p1.driver.waitUntil(
() => p1SecurityDialog.isLocked(),
{
timeout: 2000,
timeoutMsg: 'room did not lock for p1'
}
);
await enterLobby(p1, false);
const { p3 } = ctx;
// now fill in password
const lobbyScreen = p3.getLobbyScreen();
await lobbyScreen.enterPassword(roomPasscode);
await p3.waitToJoinMUC();
await p3.waitForIceConnected();
await p3.waitForReceiveMedia();
});
it('enable with more than two participants', async () => {
await hangupAllParticipants();
await ensureThreeParticipants();
await enableLobby();
// we need to check remote participants as isInMuc has not changed its value as
// the bug is triggered by presence with status 322 which is not handled correctly
const { p1, p2, p3 } = ctx;
await p1.waitForRemoteStreams(2);
await p2.waitForRemoteStreams(2);
await p3.waitForRemoteStreams(2);
});
it('moderator leaves while lobby enabled', async () => {
// The test below is only correct when the environment is configured to automatically elect a new moderator
// when the moderator leaves. For environments where this is not the case, the test is skipped.
if (!expectations.autoModerator) {
return;
}
const { p1, p2, p3 } = ctx;
await p3.hangup();
await p1.hangup();
await p2.driver.waitUntil(
() => p2.isModerator(),
{
timeout: 3000,
timeoutMsg: 'p2 is not moderator after p1 leaves'
}
);
const lobbyScreen = p2.getLobbyScreen();
expect(await lobbyScreen.isLobbyRoomJoined()).toBe(true);
});
it('reject and approve in pre-join', async () => {
await hangupAllParticipants();
await ensureTwoParticipants();
await enableLobby();
const { p1, p2 } = ctx;
const knockingParticipant = await enterLobby(p1, true, true);
// moderator rejects access
const p1ParticipantsPane = p1.getParticipantsPane();
await p1ParticipantsPane.open();
await p1ParticipantsPane.rejectLobbyParticipant(knockingParticipant);
await p1ParticipantsPane.close();
const { p3 } = ctx;
// check the denied one is out of lobby, sees the notification about it
// The third participant should see a warning that his access to the room was denied
await p3.getNotifications().waitForLobbyAccessDeniedNotification();
// check Lobby room left
expect(await p3.getLobbyScreen().isLobbyRoomJoined()).toBe(false);
// try again entering the lobby with the third one and approve it
// check that everything is fine in the meeting
await p3.getNotifications().closeLocalLobbyAccessDenied();
// let's retry to enter the lobby and approve this time
const lobbyScreen = p3.getPreJoinScreen();
// click join button
await lobbyScreen.getJoinButton().click();
await lobbyScreen.waitToJoinLobby();
// check that moderator (participant 1) sees notification about participant in lobby
const name = await p1.getNotifications().getKnockingParticipantName();
expect(name).toBe(P3);
expect(await lobbyScreen.isLobbyRoomJoined()).toBe(true);
await p1ParticipantsPane.open();
await p1ParticipantsPane.admitLobbyParticipant(knockingParticipant);
await p1ParticipantsPane.close();
await p3.waitForParticipants(2);
await p3.waitForRemoteStreams(2);
expect(await p3.getFilmstrip().countVisibleThumbnails()).toBe(3);
// Check that there are no gaps in the filmstrip after joining from lobby
await p1.getFilmstrip().assertNoGapsInFilmstrip();
await p2.getFilmstrip().assertNoGapsInFilmstrip();
await p3.getFilmstrip().assertNoGapsInFilmstrip();
});
});
/**
* Enable lobby and check that it is enabled.
*/
async function enableLobby() {
const { p1, p2 } = ctx;
const p1SecurityDialog = p1.getSecurityDialog();
await p1.getToolbar().clickSecurityButton();
await p1SecurityDialog.waitForDisplay();
expect(await p1SecurityDialog.isLobbyEnabled()).toBe(false);
await p1SecurityDialog.toggleLobby();
await p1SecurityDialog.waitForLobbyEnabled();
expect(await p2.getNotifications().getLobbyEnabledText()).toContain(p1.name);
await p2.getNotifications().closeLobbyEnabled();
const p2SecurityDialog = p2.getSecurityDialog();
await p2.getToolbar().clickSecurityButton();
await p2SecurityDialog.waitForDisplay();
// lobby is visible to moderators only, this depends on whether deployment is all moderators or not
if (await p2.isModerator()) {
await p2SecurityDialog.waitForLobbyEnabled();
} else {
expect(await p2SecurityDialog.isLobbySectionPresent()).toBe(false);
}
// let's close the security dialog, or we will not be able to click
// on popups for allow/deny participants
await p1SecurityDialog.clickCloseButton();
await p2SecurityDialog.clickCloseButton();
}
/**
* Expects that lobby is enabled for the room we will try to join.
* Lobby UI is shown, enter display name and join.
* Checks Lobby UI and also that when joining the moderator sees corresponding notifications.
*
* @param participant The participant that is moderator in the meeting.
* @param enterDisplayName whether to enter display name. We need to enter display name only the first time when
* a participant sees the lobby screen, next time visiting the page display name will be pre-filled
* from local storage.
* @param usePreJoin
* @return the participant name knocking.
*/
async function enterLobby(participant: Participant, enterDisplayName = false, usePreJoin = false) {
const options: IJoinOptions = { };
options.configOverwrite = {
prejoinConfig: {
enabled: usePreJoin
}
};
await ensureThreeParticipants({
...options,
skipDisplayName: true,
skipWaitToJoin: true,
skipInMeetingChecks: true,
skipPrejoinButtonClick: true
});
const { p3 } = ctx;
let screen;
if (usePreJoin) {
screen = p3.getPreJoinScreen();
} else {
screen = p3.getLobbyScreen();
}
// participant 3 should be now on pre-join screen
await screen.waitForLoading();
const displayNameInput = screen.getDisplayNameInput();
// check display name is visible
expect(await displayNameInput.isExisting()).toBe(true);
expect(await displayNameInput.isDisplayed()).toBe(true);
const joinButton = screen.getJoinButton();
expect(await joinButton.isExisting()).toBe(true);
if (enterDisplayName) {
let classes = await joinButton.getAttribute('class') || '';
if (!usePreJoin) {
// check join button is disabled
expect(classes).toContain('disabled');
}
// TODO check that password is hidden as the room does not have password
// this check needs to be added once the functionality exists
// enter display name
await screen.enterDisplayName(P3);
// check join button is enabled
classes = await joinButton.getAttribute('class') || '';
expect(classes).not.toContain('disabled');
}
// click join button
await screen.getJoinButton().click();
await screen.waitToJoinLobby();
// check no join button
await p3.driver.waitUntil(
async () => !await joinButton.isExisting() || !await joinButton.isDisplayed() || !await joinButton.isEnabled(),
{
timeout: 2_000,
timeoutMsg: 'Join button is still available for p3'
});
// new screen, is password button shown
const passwordButton = screen.getPasswordButton();
expect(await passwordButton.isExisting()).toBe(true);
expect(await passwordButton.isEnabled()).toBe(true);
// check that moderator (participant 1) sees notification about participant in lobby
const name = await participant.getNotifications().getKnockingParticipantName();
expect(name).toBe(P3);
expect(await screen.isLobbyRoomJoined()).toBe(true);
return name;
}

View File

@@ -0,0 +1,183 @@
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { expectations } from '../../helpers/expectations';
import { ensureOneParticipant, ensureTwoParticipants, joinSecondParticipant } from '../../helpers/participants';
import type SecurityDialog from '../../pageobjects/SecurityDialog';
setTestProperties(__filename, {
description: '1. Set a room password (assert the image changes to locked). \
2. Join with a second participant. \
3. Assert password is required (and padlock is locked). \
4. Assert wrong password fails. \
5. Unlock the room (assert the padlock is unlocked) \
6. Assert room is unlocked and the padlock is unlocked.',
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Lock room', () => {
let p1: Participant, p2: Participant;
let roomKey: string;
it('setup', async () => {
if (!expectations.moderation.setPasswordAvailable) {
ctx.skipSuiteTests = 'setPasswordAvailable is not expected to be available';
return;
}
await ensureOneParticipant();
p1 = ctx.p1;
roomKey = `${Math.trunc(Math.random() * 1_000_000)}`;
await setPassword(p1, roomKey);
});
it('enter participant in locked room', async () => {
// first enter wrong pin then correct one
await joinSecondParticipant({
skipWaitToJoin: true
});
p2 = ctx.p2;
const p2PasswordDialog = p2.getPasswordDialog();
// Submit a wrong password
await p2PasswordDialog.waitForDialog();
await p2PasswordDialog.submitPassword(`${roomKey}1234`);
// give some time to the password prompt to disappear and send the password
// TODO: wait until the dialog is not displayed? Assert the room is not joined?
await p2.driver.pause(500);
// Submit the correct password
await p2PasswordDialog.waitForDialog();
await p2PasswordDialog.submitPassword(roomKey);
await p2.waitToJoinMUC();
const p2SecurityDialog = p2.getSecurityDialog();
await p2.getToolbar().clickSecurityButton();
await p2SecurityDialog.waitForDisplay();
await waitForRoomLockState(p2SecurityDialog, true);
});
it('unlock room', async () => {
await p2.hangup();
await removePassword(p1);
});
it('join the unlocked room', async () => {
// Just enter the room and check that is not locked.
await ensureTwoParticipants();
p2 = ctx.p2;
const p2SecurityDialog = p2.getSecurityDialog();
await p2.getToolbar().clickSecurityButton();
await p2SecurityDialog.waitForDisplay();
await waitForRoomLockState(p2SecurityDialog, false);
await p2SecurityDialog.clickCloseButton();
});
it('set password while participants are in the room', async () => {
// Both participants are in unlocked room, lock it and see whether the
// change is reflected on the second participant icon.
roomKey = `${Math.trunc(Math.random() * 1_000_000)}`;
await setPassword(p1, roomKey);
const p2SecurityDialog = p2.getSecurityDialog();
await p2.getToolbar().clickSecurityButton();
await p2SecurityDialog.waitForDisplay();
await waitForRoomLockState(p2SecurityDialog, true);
await removePassword(p1);
await waitForRoomLockState(p2SecurityDialog, false);
});
it('unlock after participant enter wrong password', async () => {
// P1 locks the room. Participant tries to enter using wrong password.
// P1 unlocks the room and Participant submits the password prompt with no password entered and
// should enter of unlocked room.
await p2.hangup();
roomKey = `${Math.trunc(Math.random() * 1_000_000)}`;
await setPassword(p1, roomKey);
await joinSecondParticipant({
skipWaitToJoin: true
});
p2 = ctx.p2;
// wait for password prompt
const p2PasswordDialog = p2.getPasswordDialog();
await p2PasswordDialog.waitForDialog();
await p2PasswordDialog.submitPassword(`${roomKey}1234`);
// give sometime to the password prompt to disappear and send the password
await p2.driver.pause(500);
// wait for password prompt
await p2PasswordDialog.waitForDialog();
await removePassword(p1);
await p2PasswordDialog.clickOkButton();
await p2.waitToJoinMUC();
const p2SecurityDialog = p2.getSecurityDialog();
await p2.getToolbar().clickSecurityButton();
await p2SecurityDialog.waitForDisplay();
await waitForRoomLockState(p2SecurityDialog, false);
});
});
/**
* Set a room password via the UI.
*/
async function setPassword(p: Participant, password: string) {
const securityDialog = p.getSecurityDialog();
await p.getToolbar().clickSecurityButton();
await securityDialog.waitForDisplay();
await waitForRoomLockState(securityDialog, false);
await securityDialog.addPassword(password);
await securityDialog.clickCloseButton();
await p.getToolbar().clickSecurityButton();
await securityDialog.waitForDisplay();
await waitForRoomLockState(securityDialog, true);
await securityDialog.clickCloseButton();
}
/**
* Remove the room password via the UI.
*/
async function removePassword(p: Participant) {
const securityDialog = p.getSecurityDialog();
await p.getToolbar().clickSecurityButton();
await securityDialog.waitForDisplay();
await securityDialog.removePassword();
await waitForRoomLockState(securityDialog, false);
await securityDialog.clickCloseButton();
}
/**
* Waits for the room to be locked or unlocked.
* @param securityDialog
* @param locked
*/
function waitForRoomLockState(securityDialog: SecurityDialog, locked: boolean) {
return securityDialog.participant.driver.waitUntil(
async () => await securityDialog.isLocked() === locked,
{
timeout: 3_000, // 3 seconds
timeoutMsg: `Timeout waiting for the room to unlock for ${securityDialog.participant.name}.`
}
);
}

View File

@@ -0,0 +1,42 @@
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { expectations } from '../../helpers/expectations';
import { joinMuc } from '../../helpers/joinMuc';
setTestProperties(__filename, {
description: 'This test asserts that participants have the expected role ("moderator" or not). Failures here\
most likely indicate that the environment or test framework is misconfigured.',
usesBrowsers: [ 'p1', 'p2' ]
});
// Just make sure that users are given moderator rights as specified in the expectations config.
describe('Moderation', () => {
let p1: Participant, p2: Participant;
it('setup', async () => {
p1 = await joinMuc({ name: 'p1', token: testsConfig.jwt.preconfiguredToken });
p2 = await joinMuc({ name: 'p2', token: testsConfig.jwt.preconfiguredToken });
});
it('first moderator', async () => {
if (expectations.moderation.firstModerator) {
expect(await p1.isModerator()).toBe(true);
} else {
expect(await p1.isModerator()).toBe(false);
}
});
it('all moderators', async () => {
if (expectations.moderation.allModerators) {
expect(await p1.isModerator()).toBe(true);
expect(await p2.isModerator()).toBe(true);
}
});
it('auto moderator promotion', async () => {
if (expectations.moderation.autoModerator && !expectations.moderation.allModerators) {
expect(await p1.isModerator()).toBe(true);
expect(await p2.isModerator()).toBe(false);
await p1.hangup();
await p2.driver.waitUntil(async () => (await p2.isModerator()));
}
});
});

View File

@@ -0,0 +1,33 @@
import { ensureOneParticipant } from '../../helpers/participants';
describe('Chat panel', () => {
it('join participant', () => ensureOneParticipant());
it('start closed', async () => {
expect(await ctx.p1.getChatPanel().isOpen()).toBe(false);
});
it('open', async () => {
const { p1 } = ctx;
await p1.getToolbar().clickChatButton();
expect(await p1.getChatPanel().isOpen()).toBe(true);
});
it('use shortcut to close', async () => {
const chatPanel = ctx.p1.getChatPanel();
await chatPanel.pressShortcut();
expect(await chatPanel.isOpen()).toBe(false);
});
it('use shortcut to open', async () => {
const chatPanel = ctx.p1.getChatPanel();
await chatPanel.pressShortcut();
expect(await chatPanel.isOpen()).toBe(true);
});
it('use button to open', async () => {
const { p1 } = ctx;
await p1.getToolbar().clickCloseChatButton();
expect(await p1.getChatPanel().isOpen()).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Display name', () => {
it('joining the meeting', () => ensureTwoParticipants({ skipDisplayName: true }));
it('check change', async () => {
const { p1, p2 } = ctx;
// default remote display name
const defaultDisplayName = await p1.execute(() => config.defaultRemoteDisplayName);
const p1EndpointId = await p1.getEndpointId();
const p2EndpointId = await p2.getEndpointId();
// Checks whether default display names are set and shown, when both sides still miss the display name.
expect(await p1.getFilmstrip().getRemoteDisplayName(p2EndpointId)).toBe(defaultDisplayName);
expect(await p2.getFilmstrip().getRemoteDisplayName(p1EndpointId)).toBe(defaultDisplayName);
const randomName = `Name${Math.trunc(Math.random() * 1_000_000)}`;
await p2.setLocalDisplayName(randomName);
expect(await p2.getLocalDisplayName()).toBe(randomName);
expect(await p1.getFilmstrip().getRemoteDisplayName(p2EndpointId)).toBe(randomName);
});
it('check persistence', async () => {
const { p2 } = ctx;
const randomName = `Name${Math.trunc(Math.random() * 1_000_000)}`;
await p2.setLocalDisplayName(randomName);
expect(await p2.getLocalDisplayName()).toBe(randomName);
await p2.hangup();
await ensureTwoParticipants({
skipDisplayName: true
});
expect(await p2.getLocalDisplayName()).toBe(randomName);
});
});

View File

@@ -0,0 +1,25 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Hangup', () => {
it('joining the meeting', () => ensureTwoParticipants());
it('hangup call and check', async () => {
const { p1 } = ctx;
const url = await p1.driver.getUrl();
await p1.getToolbar().clickHangupButton();
await p1.driver.waitUntil(
async () => await p1.driver.getUrl() !== url,
{
timeout: 5000,
timeoutMsg: 'p1 did not navigate away from the conference'
}
);
});
});

View File

@@ -0,0 +1,63 @@
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { expectations } from '../../helpers/expectations';
import { joinMuc } from '../../helpers/joinMuc';
setTestProperties(__filename, {
description: ' Tests that the digits only password feature works. When the roomPasswordNumberOfDigits config \
option is set, the UI should only allow setting the password to a string of digits (with the given length).'
});
describe('Lock room with digits only', () => {
let p: Participant;
it('setup', async () => {
if (!expectations.moderation.setPasswordAvailable) {
ctx.skipSuiteTests = 'setPasswordAvailable is not expected to be available';
return;
}
p = await joinMuc({
name: 'p1',
token: testsConfig.jwt.preconfiguredToken
}, {
configOverwrite: {
roomPasswordNumberOfDigits: 5
}
});
});
it('config value', async () => {
expect(await p.execute(
() => APP.store.getState()['features/base/config'].roomPasswordNumberOfDigits)).toBe(5);
});
it('set an invalid password', async () => {
const securityDialog = p.getSecurityDialog();
await p.getToolbar().clickSecurityButton();
await securityDialog.waitForDisplay();
expect(await securityDialog.isLocked()).toBe(false);
// Set a non-numeric password.
await securityDialog.addPassword('AAAAA');
expect(await securityDialog.isLocked()).toBe(false);
await securityDialog.clickCloseButton();
});
it('set a valid password', async () => {
const securityDialog = p.getSecurityDialog();
await p.getToolbar().clickSecurityButton();
await securityDialog.waitForDisplay();
await securityDialog.addPassword('12345');
await securityDialog.clickCloseButton();
await p.getToolbar().clickSecurityButton();
await securityDialog.waitForDisplay();
expect(await securityDialog.isLocked()).toBe(true);
});
});

View File

@@ -0,0 +1,46 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
description: 'Checks the filmstrip visibility when joining from a mobile browser.',
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Filmstrip on a mobile browser', () => {
before(async () => {
// Make p2's Chrome look like an iPhone so Platform.OS resolves to 'ios' and isMobileBrowser()
// returns true. emulate() persists for the session.
await multiRemoteBrowser.getInstance('p2').emulate('device', 'iPhone 15');
});
it('filmstrip and self view are visible when joining a 1:1 call', async () => {
await ensureTwoParticipants({
configOverwrite: {
// The deployment config may show the deep linking page on a mobile browser; disable it.
deeplinking: {
disabled: true
},
// The default config for tests disables 1-on-1 mode, enable it to test it.
disable1On1Mode: false
}
});
const { p2 } = ctx;
// Make sure the mobile emulation is in effect, otherwise the test silently degrades into
// testing the desktop UI.
expect(await p2.execute(() => navigator.userAgent)).toContain('iPhone');
expect(await p2.execute(() => window.innerWidth)).toBeLessThan(500);
// The whole filmstrip (including the self view) must not be hidden when joining a 1:1 call
// on a narrow/mobile client.
await p2.getFilmstrip().assertVisible();
await p2.getFilmstrip().assertSelfViewIsHidden(false);
// On mobile browsers the remote thumbnails are hidden in 1-on-1 mode even when the toolbar is
// visible (the remote participant is already on the stage), as opposed to desktop where their
// visibility follows the toolbar one.
await p2.getFilmstrip().verifyRemoteVideosDisplay(false);
});
});

View File

@@ -0,0 +1,86 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import {
ensureThreeParticipants,
ensureTwoParticipants
} from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
const ONE_ON_ONE_CONFIG_OVERRIDES = {
configOverwrite: {
disable1On1Mode: false,
toolbarConfig: {
timeout: 500,
alwaysVisible: false
}
}
};
describe('One-on-one (1on1) mode', () => {
it('filmstrip hidden in 1on1', async () => {
await ensureTwoParticipants(ONE_ON_ONE_CONFIG_OVERRIDES);
const { p1, p2 } = ctx;
await configureToolbarsToHideQuickly(p1);
await configureToolbarsToHideQuickly(p2);
await p1.getFilmstrip().verifyRemoteVideosDisplay(false);
await p2.getFilmstrip().verifyRemoteVideosDisplay(false);
});
it('filmstrip visible with more than 2', async () => {
await ensureThreeParticipants(ONE_ON_ONE_CONFIG_OVERRIDES);
const { p1, p2, p3 } = ctx;
await configureToolbarsToHideQuickly(p3);
await p1.getFilmstrip().verifyRemoteVideosDisplay(true);
await p2.getFilmstrip().verifyRemoteVideosDisplay(true);
await p3.getFilmstrip().verifyRemoteVideosDisplay(true);
});
it('filmstrip display when returning to 1on1', async () => {
const { p1, p2, p3 } = ctx;
await p2.getFilmstrip().pinParticipant(p2);
await p3.hangup();
await p1.getFilmstrip().verifyRemoteVideosDisplay(false);
await p2.getFilmstrip().verifyRemoteVideosDisplay(true);
});
it('filmstrip visible on self view focus', async () => {
const { p1 } = ctx;
await p1.getFilmstrip().pinParticipant(p1);
await p1.getFilmstrip().verifyRemoteVideosDisplay(true);
await p1.getFilmstrip().unpinParticipant(p1);
await p1.getFilmstrip().verifyRemoteVideosDisplay(false);
});
it('filmstrip hover show videos', async () => {
const { p1 } = ctx;
await p1.getFilmstrip().hoverOverLocalVideo();
await p1.getFilmstrip().verifyRemoteVideosDisplay(true);
});
});
/**
* Hangs up all participants (p1, p2 and p3)
* @returns {Promise<void>}
*/
function configureToolbarsToHideQuickly(participant: Participant): Promise<void> {
return participant.execute(() => {
APP.UI.dockToolbar(false);
APP.UI.showToolbar(250);
});
}

View File

@@ -0,0 +1,130 @@
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureOneParticipant, joinFirstParticipant, joinSecondParticipant } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Pre-join screen', () => {
it('display name required', async () => {
await joinFirstParticipant({
configOverwrite: {
prejoinConfig: {
enabled: true,
},
requireDisplayName: true
},
skipDisplayName: true,
skipWaitToJoin: true,
skipPrejoinButtonClick: true
});
const p1PreJoinScreen = ctx.p1.getPreJoinScreen();
await p1PreJoinScreen.waitForLoading();
const joinButton = p1PreJoinScreen.getJoinButton();
await joinButton.waitForDisplayed();
await joinButton.click();
const error = p1PreJoinScreen.getErrorOnJoin();
await error.waitForDisplayed();
await ctx.p1.hangup();
});
it('without lobby', async () => {
await joinFirstParticipant({
configOverwrite: {
prejoinConfig: {
enabled: true,
}
},
skipDisplayName: true,
skipWaitToJoin: true,
skipPrejoinButtonClick: true
});
const p1PreJoinScreen = ctx.p1.getPreJoinScreen();
await p1PreJoinScreen.waitForLoading();
const joinButton = p1PreJoinScreen.getJoinButton();
await joinButton.waitForDisplayed();
await ctx.p1.hangup();
});
it('without audio', async () => {
await joinFirstParticipant({
configOverwrite: {
prejoinConfig: {
enabled: true,
}
},
skipDisplayName: true,
skipWaitToJoin: true,
skipPrejoinButtonClick: true
});
const { p1 } = ctx;
const p1PreJoinScreen = p1.getPreJoinScreen();
await p1PreJoinScreen.waitForLoading();
await p1PreJoinScreen.getJoinOptions().click();
const joinWithoutAudioBtn = p1PreJoinScreen.getJoinWithoutAudioButton();
await joinWithoutAudioBtn.waitForClickable();
await joinWithoutAudioBtn.click();
await p1.waitToJoinMUC();
await p1.driver.$('//div[contains(@class, "audio-preview")]//div[contains(@class, "toolbox-icon") '
+ 'and contains(@class, "toggled") and contains(@class, "disabled")]')
.waitForDisplayed();
await ctx.p1.hangup();
});
it('with lobby', async () => {
await ensureOneParticipant();
const { p1 } = ctx;
const p1SecurityDialog = p1.getSecurityDialog();
await p1.getToolbar().clickSecurityButton();
await p1SecurityDialog.waitForDisplay();
expect(await p1SecurityDialog.isLobbyEnabled()).toBe(false);
await p1SecurityDialog.toggleLobby();
await p1SecurityDialog.waitForLobbyEnabled();
await joinSecondParticipant({
configOverwrite: {
prejoinConfig: {
enabled: true,
}
},
skipDisplayName: true,
skipWaitToJoin: true,
skipPrejoinButtonClick: true
});
const p1PreJoinScreen = ctx.p2.getPreJoinScreen();
await p1PreJoinScreen.waitForLoading();
const joinButton = p1PreJoinScreen.getJoinButton();
await joinButton.waitForDisplayed();
});
});

View File

@@ -0,0 +1,73 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureTwoParticipants } from '../../helpers/participants';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Self view', () => {
it('joining the meeting', () => ensureTwoParticipants());
it('hide from menu', async () => {
const { p1 } = ctx;
await checkSelfViewHidden(p1, false);
await p1.getFilmstrip().hideSelfView();
await checkSelfViewHidden(p1, true, true);
await p1.getToolbar().clickEnterTileViewButton();
await checkSelfViewHidden(p1, true);
});
it('show from settings', async () => {
const { p1 } = ctx;
await toggleSelfViewFromSettings(p1, false);
await checkSelfViewHidden(p1, false);
});
it('hide from settings', async () => {
const { p1 } = ctx;
await toggleSelfViewFromSettings(p1, true);
await checkSelfViewHidden(p1, true, true);
});
it('check in alone meeting', async () => {
const { p1, p2 } = ctx;
await checkSelfViewHidden(p1, true);
await p2.hangup();
await checkSelfViewHidden(p1, true);
});
});
/**
* Toggles the self view option from the settings dialog.
*/
async function toggleSelfViewFromSettings(participant: Participant, hide: boolean) {
await participant.getToolbar().clickSettingsButton();
const settings = participant.getSettingsDialog();
await settings.waitForDisplay();
await settings.setHideSelfView(hide);
await settings.submit();
}
/**
* Checks whether the local self view is displayed or not.
*/
async function checkSelfViewHidden(participant: Participant, hidden: boolean, checkNotification = false) {
if (checkNotification) {
await participant.getNotifications().waitForReEnableSelfViewNotification();
await participant.getNotifications().closeReEnableSelfViewNotification();
}
await participant.getFilmstrip().assertSelfViewIsHidden(hidden);
}

View File

@@ -0,0 +1,58 @@
import { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { config as testsConfig } from '../../helpers/TestsConfig';
import { joinMuc } from '../../helpers/joinMuc';
/**
* The CSS selector for local video when outside of tile view. It should
* be in a container separate from remote videos so remote videos can
* scroll while local video stays docked.
*/
const FILMSTRIP_VIEW_LOCAL_VIDEO_CSS_SELECTOR = '#filmstripLocalVideo #localVideoContainer';
/**
* The CSS selector for local video tile view is enabled. It should display
* at the end of all the other remote videos, as the last tile.
*/
const TILE_VIEW_LOCAL_VIDEO_CSS_SELECTOR = '.remote-videos #localVideoContainer';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Tile view', () => {
let p1: Participant, p2: Participant;
before('join the meeting', async () => {
p1 = await joinMuc({ name: 'p1', token: testsConfig.jwt.preconfiguredToken });
p2 = await joinMuc({ name: 'p2' });
});
it('entering tile view', async () => {
await p1.getToolbar().clickEnterTileViewButton();
await p1.waitForTileViewDisplayed();
});
it('exit tile view by pinning', async () => {
await p1.getFilmstrip().pinParticipant(p2);
await p1.waitForTileViewDisplayed(true);
});
it('local video is displayed in tile view', async () => {
await p1.getToolbar().clickEnterTileViewButton();
await p1.waitForTileViewDisplayed();
await p1.driver.$(TILE_VIEW_LOCAL_VIDEO_CSS_SELECTOR).waitForDisplayed({ timeout: 3000 });
await p1.driver.$(FILMSTRIP_VIEW_LOCAL_VIDEO_CSS_SELECTOR).waitForDisplayed({
timeout: 3000,
reverse: true
});
});
it('exit tile view by clicking "exit tile view"', async () => {
await p1.getToolbar().clickExitTileViewButton();
await p1.waitForTileViewDisplayed(true);
});
it('local video display independently from remote', async () => {
await p1.driver.$(TILE_VIEW_LOCAL_VIDEO_CSS_SELECTOR).waitForDisplayed({
timeout: 3000,
reverse: true
});
await p1.driver.$(FILMSTRIP_VIEW_LOCAL_VIDEO_CSS_SELECTOR).waitForDisplayed({ timeout: 3000 });
});
});

View File

@@ -0,0 +1,27 @@
import { ensureOneParticipant } from '../../helpers/participants';
describe('Video layout', () => {
it('join participant', () => ensureOneParticipant());
it('check', async () => {
const { p1 } = ctx;
const innerWidth = parseInt(await p1.execute('return window.innerWidth'), 10);
const innerHeight = parseInt(await p1.execute('return window.innerHeight'), 10);
const largeVideo = p1.driver.$('//div[@id="largeVideoContainer"]');
const filmstrip = p1.driver.$('//div[contains(@class, "filmstrip")]');
let filmstripWidth;
if (!await filmstrip.isExisting() || !await filmstrip.isDisplayed()) {
filmstripWidth = 0;
} else {
filmstripWidth = await filmstrip.getSize('width');
}
const largeVideoSize = await largeVideo.getSize();
expect((largeVideoSize.width === (innerWidth - filmstripWidth)) || (largeVideoSize.height === innerHeight))
.toBe(true);
});
});