snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
214
tests/specs/misc/avatars.spec.ts
Normal file
214
tests/specs/misc/avatars.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
487
tests/specs/misc/breakoutRooms.spec.ts
Normal file
487
tests/specs/misc/breakoutRooms.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
255
tests/specs/misc/connectivity.spec.ts
Normal file
255
tests/specs/misc/connectivity.spec.ts
Normal 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());
|
||||
}
|
||||
125
tests/specs/misc/dialIn.spec.ts
Normal file
125
tests/specs/misc/dialIn.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
98
tests/specs/misc/followMe.spec.ts
Normal file
98
tests/specs/misc/followMe.spec.ts
Normal 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'
|
||||
});
|
||||
});
|
||||
});
|
||||
128
tests/specs/misc/polls.spec.ts
Normal file
128
tests/specs/misc/polls.spec.ts
Normal 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%)');
|
||||
});
|
||||
});
|
||||
143
tests/specs/misc/reactionShortcuts.spec.ts
Normal file
143
tests/specs/misc/reactionShortcuts.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
94
tests/specs/misc/recordingButtonVisibility.spec.ts
Normal file
94
tests/specs/misc/recordingButtonVisibility.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
371
tests/specs/misc/recordingDialogConfig.spec.ts
Normal file
371
tests/specs/misc/recordingDialogConfig.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
193
tests/specs/misc/recordingTranscriptionDialog.spec.ts
Normal file
193
tests/specs/misc/recordingTranscriptionDialog.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
37
tests/specs/misc/subject.spec.ts
Normal file
37
tests/specs/misc/subject.spec.ts
Normal 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);
|
||||
}
|
||||
125
tests/specs/misc/timeTimer.spec.ts
Normal file
125
tests/specs/misc/timeTimer.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
84
tests/specs/misc/urlNormalisation.spec.ts
Normal file
84
tests/specs/misc/urlNormalisation.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
225
tests/specs/misc/virtualBackground.spec.ts
Normal file
225
tests/specs/misc/virtualBackground.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
336
tests/specs/misc/virtualBackgroundV2.spec.ts
Normal file
336
tests/specs/misc/virtualBackgroundV2.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
36
tests/specs/misc/xmppConferenceRequest.spec.ts
Normal file
36
tests/specs/misc/xmppConferenceRequest.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user