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