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} */ async assertAudioMuteIconIsDisplayed(testee: Participant, reverse = false): Promise { 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) { const remoteDisplayName = this.participant.driver.$(`span[id="participant_${endpointId}"]`); await remoteDisplayName.moveTo(); 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 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 { 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 { 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' }` ); } } }