snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
390
tests/pageobjects/Filmstrip.ts
Normal file
390
tests/pageobjects/Filmstrip.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { Participant } from '../helpers/Participant';
|
||||
|
||||
import BaseDialog from './BaseDialog';
|
||||
import BasePageObject from './BasePageObject';
|
||||
|
||||
const LOCAL_VIDEO_XPATH = '//span[@id="localVideoContainer"]';
|
||||
const LOCAL_VIDEO_MENU_TRIGGER = '#local-video-menu-trigger';
|
||||
const LOCAL_USER_CONTROLS = 'button[title="Local user controls"]';
|
||||
const HIDE_SELF_VIEW_BUTTON_XPATH = '//div[contains(@class, "popover")]//div[@id="hideselfviewButton"]';
|
||||
|
||||
/**
|
||||
* Filmstrip elements.
|
||||
*/
|
||||
export default class Filmstrip extends BasePageObject {
|
||||
/**
|
||||
* Asserts that {@code participant} shows or doesn't show the audio
|
||||
* mute icon for the conference participant identified by
|
||||
* {@code testee}.
|
||||
*
|
||||
* @param {Participant} testee - The {@code Participant} for whom we're checking the status of audio muted icon.
|
||||
* @param {boolean} reverse - If {@code true}, the method will assert the absence of the "mute" icon;
|
||||
* otherwise, it will assert its presence.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async assertAudioMuteIconIsDisplayed(testee: Participant, reverse = false): Promise<void> {
|
||||
let id;
|
||||
|
||||
if (testee === this.participant) {
|
||||
id = 'localVideoContainer';
|
||||
} else {
|
||||
id = `participant_${await testee.getEndpointId()}`;
|
||||
}
|
||||
|
||||
const mutedIconXPath
|
||||
= `//span[@id='${id}']//span[contains(@id, 'audioMuted')]//*[local-name()='svg' and @id='mic-disabled']`;
|
||||
|
||||
await this.participant.driver.$(mutedIconXPath).waitForDisplayed({
|
||||
reverse,
|
||||
timeout: 5_000,
|
||||
timeoutMsg: `Audio mute icon is${reverse ? '' : ' not'} displayed for ${testee.name}`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the remote display name for an endpoint.
|
||||
* @param endpointId The endpoint id.
|
||||
*/
|
||||
async getRemoteDisplayName(endpointId: string) {
|
||||
const remoteDisplayName = this.participant.driver.$(`span[id="participant_${endpointId}_name"]`);
|
||||
|
||||
await remoteDisplayName.moveTo();
|
||||
|
||||
return await remoteDisplayName.getText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the remote video id of a participant with endpointID.
|
||||
* @param endpointId
|
||||
*/
|
||||
async getRemoteVideoId(endpointId: string) {
|
||||
await this.participant.driver.$(`span[id="participant_${endpointId}"]`).waitForExist();
|
||||
|
||||
return await this.participant.execute(eId =>
|
||||
document.evaluate(`//span[@id="participant_${eId}"]//video`,
|
||||
document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue?.srcObject?.id, endpointId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local video id.
|
||||
*/
|
||||
getLocalVideoId() {
|
||||
return this.participant.execute(
|
||||
'return document.getElementById("localVideo_container").srcObject.id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins a participant by clicking on their thumbnail.
|
||||
* @param participant The participant.
|
||||
*/
|
||||
async pinParticipant(participant: Participant) {
|
||||
if (participant === this.participant) {
|
||||
// when looking up the element and clicking it, it doesn't work if we do it twice in a row (oneOnOne.spec)
|
||||
await this.participant.execute(() => document?.getElementById('localVideoContainer')?.click());
|
||||
} else {
|
||||
const epId = await participant.getEndpointId();
|
||||
|
||||
await this.participant.driver.$(`//span[@id="participant_${epId}"]`).click();
|
||||
}
|
||||
const endpointID = await participant.getEndpointId();
|
||||
|
||||
await this.participant.waitForParticipantOnLargeVideo(endpointID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpins a participant by clicking on their thumbnail.
|
||||
* @param participant
|
||||
*/
|
||||
async unpinParticipant(participant: Participant) {
|
||||
const epId = await participant.getEndpointId();
|
||||
|
||||
if (participant === this.participant) {
|
||||
await this.participant.execute(() => document?.getElementById('localVideoContainer')?.click());
|
||||
} else {
|
||||
await this.participant.driver.$(`//span[@id="participant_${epId}"]`).click();
|
||||
}
|
||||
|
||||
await this.participant.driver.$(`//div[ @id="pin-indicator-${epId}" ]`).waitForDisplayed({
|
||||
timeout: 2_000,
|
||||
timeoutMsg: `${this.participant.name} did not unpin ${participant.name}`,
|
||||
reverse: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets avatar SRC attribute for the one displayed on small video thumbnail.
|
||||
* @param endpointId
|
||||
*/
|
||||
async getAvatar(endpointId: string) {
|
||||
const elem = this.participant.driver.$(
|
||||
`//span[@id='participant_${endpointId}']//img[contains(@class,'userAvatar')]`);
|
||||
|
||||
return await elem.isExisting() ? await elem.getAttribute('src') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants moderator rights to a participant.
|
||||
* @param participant
|
||||
*/
|
||||
async grantModerator(participant: Participant) {
|
||||
await this.clickOnRemoteMenuLink(await participant.getEndpointId(), 'grantmoderatorlink', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks on the link in the remote participant actions menu.
|
||||
* @param participantId
|
||||
* @param linkClassname
|
||||
* @param dialogConfirm
|
||||
* @private
|
||||
*/
|
||||
private async clickOnRemoteMenuLink(participantId: string, linkClassname: string, dialogConfirm: boolean) {
|
||||
await this.participant.driver.$(`//span[@id='participant_${participantId}']`).moveTo();
|
||||
|
||||
await this.participant.driver.$(
|
||||
`//span[@id='participant_${participantId
|
||||
}']//span[@id='remotevideomenu']//div[@id='remote-video-menu-trigger']`).moveTo();
|
||||
|
||||
const popoverElement = this.participant.driver.$(
|
||||
`//div[contains(@class, 'popover')]//div[contains(@class, '${linkClassname}')]`);
|
||||
|
||||
await popoverElement.waitForExist();
|
||||
await popoverElement.waitForDisplayed();
|
||||
await popoverElement.click();
|
||||
|
||||
if (dialogConfirm) {
|
||||
await new BaseDialog(this.participant).clickOkButton();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutes the audio of a participant.
|
||||
* @param participant
|
||||
*/
|
||||
async muteAudio(participant: Participant) {
|
||||
await this.clickOnRemoteMenuLink(await participant.getEndpointId(), 'mutelink', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutes the video of a participant.
|
||||
* @param participant
|
||||
*/
|
||||
async muteVideo(participant: Participant) {
|
||||
await this.clickOnRemoteMenuLink(await participant.getEndpointId(), 'mutevideolink', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kicks a participant.
|
||||
* @param participantId
|
||||
*/
|
||||
kickParticipant(participantId: string) {
|
||||
return this.clickOnRemoteMenuLink(participantId, 'kicklink', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover over local video.
|
||||
*/
|
||||
hoverOverLocalVideo() {
|
||||
return this.participant.driver.$(LOCAL_VIDEO_MENU_TRIGGER).moveTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks on the hide self view button from local video.
|
||||
*/
|
||||
async hideSelfView() {
|
||||
// open local video menu
|
||||
await this.hoverOverLocalVideo();
|
||||
await this.participant.driver.$(LOCAL_USER_CONTROLS).moveTo();
|
||||
|
||||
// click Hide self view button
|
||||
const hideSelfViewButton = this.participant.driver.$(HIDE_SELF_VIEW_BUTTON_XPATH);
|
||||
|
||||
await hideSelfViewButton.waitForExist();
|
||||
await hideSelfViewButton.waitForClickable();
|
||||
await hideSelfViewButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the local self view is displayed or not.
|
||||
*/
|
||||
assertSelfViewIsHidden(hidden: boolean) {
|
||||
return this.participant.driver.$(LOCAL_VIDEO_XPATH).waitForDisplayed({
|
||||
reverse: hidden,
|
||||
timeout: 5000,
|
||||
timeoutMsg: `Local video thumbnail is${hidden ? '' : ' not'} displayed for ${this.participant.name}`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the filmstrip.
|
||||
*/
|
||||
async toggle() {
|
||||
const toggleButton = this.participant.driver.$('#toggleFilmstripButton');
|
||||
|
||||
await toggleButton.moveTo();
|
||||
await toggleButton.waitForDisplayed();
|
||||
await toggleButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the filmstrip itself (the whole container, including the self view) is visible or not
|
||||
* (it is hidden for example when collapsed via the toggle button).
|
||||
*
|
||||
* @param visible Whether the filmstrip is expected to be visible.
|
||||
*/
|
||||
assertVisible(visible = true) {
|
||||
return this.participant.driver.waitUntil(
|
||||
() => this.participant.execute(
|
||||
expected => {
|
||||
const filmstrip = document.querySelector('.filmstrip');
|
||||
|
||||
return Boolean(filmstrip) && filmstrip.classList.contains('hidden') !== expected;
|
||||
}, visible),
|
||||
{
|
||||
timeout: 5_000,
|
||||
timeoutMsg: `The filmstrip is${visible ? ' not' : ''} visible for ${this.participant.name}`
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the remote videos are hidden or not.
|
||||
* @param reverse
|
||||
*/
|
||||
assertRemoteVideosHidden(reverse = false) {
|
||||
return this.participant.driver.waitUntil(
|
||||
async () =>
|
||||
await this.participant.driver.$$('//div[@id="remoteVideos" and contains(@class, "hidden")]').length > 0,
|
||||
{
|
||||
timeout: 10_000, // 10 seconds
|
||||
timeoutMsg: `Timeout waiting fore remote videos to be hidden: ${!reverse}.`
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the displayed remote video thumbnails.
|
||||
*/
|
||||
async countVisibleThumbnails() {
|
||||
return (await this.participant.driver.$$('//div[@id="remoteVideos"]//span[contains(@class,"videocontainer")]')
|
||||
.filter(thumbnail => thumbnail.isDisplayed())).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if remote videos in filmstrip are visible.
|
||||
*
|
||||
* @param isDisplayed whether or not filmstrip remote videos should be visible
|
||||
*/
|
||||
verifyRemoteVideosDisplay(isDisplayed: boolean) {
|
||||
return this.participant.driver.$('//div[contains(@class, "remote-videos")]/div').waitForDisplayed({
|
||||
timeout: 5_000,
|
||||
reverse: !isDisplayed,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for visible gaps in the filmstrip thumbnails.
|
||||
* This detects if there are any missing thumbnails or excessive spacing between consecutive visible thumbnails.
|
||||
*
|
||||
* @returns Returns true if gaps are detected, false otherwise.
|
||||
*/
|
||||
async hasGapsInFilmstrip(): Promise<boolean> {
|
||||
return await this.participant.execute(() => {
|
||||
// Get all visible thumbnail containers in the filmstrip
|
||||
const thumbnails = Array.from(
|
||||
document.querySelectorAll('#remoteVideos span.videocontainer')
|
||||
).filter((thumb: any) => {
|
||||
const style = window.getComputedStyle(thumb);
|
||||
const rect = thumb.getBoundingClientRect();
|
||||
|
||||
// Check if element is visible and has dimensions
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
});
|
||||
|
||||
if (thumbnails.length < 2) {
|
||||
// Can't have gaps with less than 2 thumbnails
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get positions and calculated margins of all visible thumbnails
|
||||
const positions = thumbnails.map((thumb: any) => {
|
||||
const rect = thumb.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(thumb);
|
||||
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
marginTop: parseFloat(style.marginTop) || 0,
|
||||
marginBottom: parseFloat(style.marginBottom) || 0,
|
||||
marginLeft: parseFloat(style.marginLeft) || 0,
|
||||
marginRight: parseFloat(style.marginRight) || 0
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate expected spacing between thumbnails based on first two
|
||||
const firstGap = positions.length >= 2
|
||||
? Math.abs(positions[1].top - positions[0].top) !== 0
|
||||
? positions[1].top - positions[0].bottom // vertical
|
||||
: positions[1].left - positions[0].right // horizontal
|
||||
: 0;
|
||||
|
||||
// Check if filmstrip is vertical or horizontal
|
||||
const isVertical = Math.abs(positions[1].top - positions[0].top) > Math.abs(positions[1].left - positions[0].left);
|
||||
|
||||
if (isVertical) {
|
||||
// For vertical filmstrip, check vertical spacing consistency
|
||||
for (let i = 0; i < positions.length - 1; i++) {
|
||||
const current = positions[i];
|
||||
const next = positions[i + 1];
|
||||
const gap = next.top - current.bottom;
|
||||
|
||||
// Compare against the first gap with some tolerance
|
||||
// Flag if gap is more than 2x the expected spacing
|
||||
if (gap > Math.max(firstGap * 2, current.height * 0.3)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For horizontal filmstrip, check horizontal spacing consistency
|
||||
for (let i = 0; i < positions.length - 1; i++) {
|
||||
const current = positions[i];
|
||||
const next = positions[i + 1];
|
||||
const gap = next.left - current.right;
|
||||
|
||||
// Compare against the first gap with some tolerance
|
||||
// Flag if gap is more than 2x the expected spacing
|
||||
if (gap > Math.max(firstGap * 2, current.width * 0.3)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that there are no gaps in the filmstrip.
|
||||
* This is useful for detecting layout issues where thumbnails might be missing or mispositioned.
|
||||
*
|
||||
* @param reverse - If true, asserts that gaps should exist. Default false.
|
||||
*/
|
||||
async assertNoGapsInFilmstrip(reverse = false): Promise<void> {
|
||||
const hasGaps = await this.hasGapsInFilmstrip();
|
||||
const expectedResult = reverse ? true : false;
|
||||
|
||||
if (hasGaps !== expectedResult) {
|
||||
throw new Error(
|
||||
`Expected filmstrip to ${reverse ? 'have' : 'not have'} gaps, but ${
|
||||
hasGaps ? 'gaps were detected' : 'no gaps were found'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user