diff --git a/tests/env.example b/tests/env.example index 01eedff4d1..34df04f711 100644 --- a/tests/env.example +++ b/tests/env.example @@ -30,3 +30,6 @@ #WEBHOOKS_PROXY_URL= # A shared secret to authenticate the webhook proxy connection #WEBHOOKS_PROXY_SHARED_SECRET= + +# A rest URL to be used by dial-in tests to invite jigasi to the conference +#DIAL_IN_REST_URL= diff --git a/tests/helpers/Participant.ts b/tests/helpers/Participant.ts index ed0f2d0561..a77714d068 100644 --- a/tests/helpers/Participant.ts +++ b/tests/helpers/Participant.ts @@ -238,11 +238,22 @@ export class Participant { async () => await this.driver.execute(() => document.readyState === 'complete'), { timeout: 30_000, // 30 seconds - timeoutMsg: 'Timeout waiting for Page Load Request to complete.' + timeoutMsg: `Timeout waiting for Page Load Request to complete for ${this.name}.` } ); } + /** + * Waits for the tile view to display. + */ + async waitForTileViewDisplay(reverse = false) { + await this.driver.$('//div[@id="videoconference_page" and contains(@class, "tile-view")]').waitForDisplayed({ + reverse, + timeout: 10_000, + timeoutMsg: `Tile view did not display in 10s for ${this.name}` + }); + } + /** * Checks if the participant is in the meeting. */ @@ -284,7 +295,7 @@ export class Participant { () => this.isInMuc(), { timeout: 10_000, // 10 seconds - timeoutMsg: 'Timeout waiting to join muc.' + timeoutMsg: `Timeout waiting to join muc for ${this.name}` } ); } @@ -300,7 +311,7 @@ export class Participant { return driver.waitUntil(async () => await driver.execute(() => APP.conference.getConnectionState() === 'connected'), { timeout: 15_000, - timeoutMsg: 'expected ICE to be connected for 15s' + timeoutMsg: `expected ICE to be connected for 15s for ${this.name}` }); } @@ -309,7 +320,8 @@ export class Participant { * * @returns {Promise} */ - async waitForSendReceiveData(timeout = 15_000, msg = 'expected to receive/send data in 15s'): Promise { + async waitForSendReceiveData( + timeout = 15_000, msg = `expected to receive/send data in 15s for ${this.name}`): Promise { const driver = this.driver; return driver.waitUntil(async () => @@ -340,7 +352,7 @@ export class Participant { return driver.waitUntil(async () => await driver.execute(count => APP.conference.getNumberOfParticipantsWithTracks() >= count, number), { timeout: 15_000, - timeoutMsg: 'expected remote streams in 15s' + timeoutMsg: `expected remote streams in 15s for ${this.name}` }); } @@ -357,7 +369,7 @@ export class Participant { return driver.waitUntil(async () => await driver.execute(count => APP.conference.listMembers().length === count, number), { timeout: 15_000, - timeoutMsg: msg || `not the expected participants ${number} in 15s` + timeoutMsg: msg || `not the expected participants ${number} in 15s for ${this.name}` }); } @@ -531,6 +543,13 @@ export class Participant { return await this.driver.execute(() => APP.UI.getLargeVideoID()); } + /** + * Returns the source of the large video currently shown. + */ + async getLargeVideoId() { + return await this.driver.execute('return document.getElementById("largeVideo").srcObject.id'); + } + /** * Makes sure that the avatar is displayed in the local thumbnail and that the video is not displayed. * There are 3 options for avatar: diff --git a/tests/pageobjects/Filmstrip.ts b/tests/pageobjects/Filmstrip.ts index 7a529a1494..75d00f0070 100644 --- a/tests/pageobjects/Filmstrip.ts +++ b/tests/pageobjects/Filmstrip.ts @@ -53,6 +53,28 @@ export default class Filmstrip extends BasePageObject { 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.driver.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. + */ + async getLocalVideoId() { + return await this.participant.driver.execute( + 'return document.getElementById("localVideo_container").srcObject.id'); + } + /** * Pins a participant by clicking on their thumbnail. * @param participant The participant. @@ -162,4 +184,30 @@ export default class Filmstrip extends BasePageObject { 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 + */ + async assertRemoteVideosHidden(reverse = false) { + await 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}.` + } + ); + } } diff --git a/tests/pageobjects/InviteDialog.ts b/tests/pageobjects/InviteDialog.ts index 0ed9fe5869..e938ec65a4 100644 --- a/tests/pageobjects/InviteDialog.ts +++ b/tests/pageobjects/InviteDialog.ts @@ -1,7 +1,10 @@ import BaseDialog from './BaseDialog'; const CONFERENCE_ID = 'conference-id'; +const CONFERENCE_URL = 'invite-more-dialog-conference-url'; const DIALOG_CONTAINER = 'invite-more-dialog'; +const MORE_NUMBERS = 'more-numbers'; +const PHONE_NUMBER = 'phone-number'; /** * Represents the invite dialog in a particular participant. @@ -31,7 +34,18 @@ export default class InviteDialog extends BaseDialog { async getPinNumber() { await this.open(); - const elem = this.participant.driver.$(`.${CONFERENCE_ID}`); + return (await this.getValueAfterColon(CONFERENCE_ID)).replace(/[# ]/g, ''); + } + + /** + * Private helper to get values after colons. The invite dialog lists conference specific information + * after a label, followed by a colon. + * + * @param className + * @private + */ + private async getValueAfterColon(className: string) { + const elem = this.participant.driver.$(`.${className}`); await elem.waitForExist({ timeout: 5000 }); @@ -39,6 +53,50 @@ export default class InviteDialog extends BaseDialog { this.participant.log(`Extracted text in invite dialog: ${fullText}`); - return fullText.split(':')[1].trim().replace(/[# ]/g, ''); + return fullText.split(':')[1].trim(); + } + + /** + * Returns the meeting url displayed in the dialog. + */ + async getMeetingURL() { + const elem = this.participant.driver.$(`.${CONFERENCE_URL}`); + + await elem.waitForExist(); + + return (await elem.getText())?.trim(); + } + + /** + * Waits for the dialog to be open or closed. + * @param reverse + */ + async waitTillOpen(reverse = false) { + await this.participant.driver.waitUntil( + /* eslint-disable no-extra-parens */ + async () => (reverse ? !await this.isOpen() : await this.isOpen()), + { + timeout: 2_000, + timeoutMsg: `invite dialog did not ${reverse ? 'close' : 'open'}` + } + ); + } + + /** + * Gets the string that contains the dial in number for the current conference. + */ + async getDialInNumber() { + return await this.getValueAfterColon(PHONE_NUMBER); + } + + /** + * Clicks the link to open a page to show all available dial in numbers. + */ + async openDialInNumbersPage() { + const moreNumbers = this.participant.driver.$(`.${MORE_NUMBERS}`); + + await moreNumbers.waitForExist(); + await moreNumbers.waitForClickable(); + await moreNumbers.click(); } } diff --git a/tests/pageobjects/SettingsDialog.ts b/tests/pageobjects/SettingsDialog.ts index dbcdc56cc7..9bc5cf9757 100644 --- a/tests/pageobjects/SettingsDialog.ts +++ b/tests/pageobjects/SettingsDialog.ts @@ -1,8 +1,10 @@ import BaseDialog from './BaseDialog'; const EMAIL_FIELD = '#setEmail'; +const FOLLOW_ME_CHECKBOX = '//input[@name="follow-me"]'; const HIDE_SELF_VIEW_CHECKBOX = '//input[@name="hide-self-view"]'; const SETTINGS_DIALOG_CONTENT = '.settings-pane'; +const X_PATH_MODERATOR_TAB = '//div[contains(@class, "settings-dialog")]//*[text()="Moderator"]'; const X_PATH_MORE_TAB = '//div[contains(@class, "settings-dialog")]//*[text()="General"]'; const X_PATH_PROFILE_TAB = '//div[contains(@class, "settings-dialog")]//*[text()="Profile"]'; @@ -37,12 +39,19 @@ export default class SettingsDialog extends BaseDialog { } /** - * Selects the Profile tab to be displayed. + * Selects the More tab to be displayed. */ async openMoreTab() { await this.openTab(X_PATH_MORE_TAB); } + /** + * Selects the moderator tab to be displayed. + */ + async openModeratorTab() { + await this.openTab(X_PATH_MODERATOR_TAB); + } + /** * Enters the passed in email into the email field. * @param email @@ -75,14 +84,49 @@ export default class SettingsDialog extends BaseDialog { async setHideSelfView(hideSelfView: boolean) { await this.openMoreTab(); - const checkbox = this.participant.driver.$(HIDE_SELF_VIEW_CHECKBOX); + await this.setCheckbox(HIDE_SELF_VIEW_CHECKBOX, hideSelfView); + } + + /** + * Sets the follow me feature to enabled/disabled. + * @param enable + */ + async setFollowMe(enable: boolean) { + await this.openModeratorTab(); + + await this.setCheckbox(FOLLOW_ME_CHECKBOX, enable); + } + + /** + * Returns true if the follow me checkbox is displayed in the settings dialog. + */ + async isFollowMeDisplayed() { + const elem = this.participant.driver.$(X_PATH_MODERATOR_TAB); + + if (!await elem.isExisting()) { + return false; + } + + await this.openModeratorTab(); + + return await this.participant.driver.$$(FOLLOW_ME_CHECKBOX).length > 0; + } + + /** + * Sets the state of a checkbox. + * @param selector + * @param enable + * @private + */ + private async setCheckbox(selector: string, enable: boolean) { + const checkbox = this.participant.driver.$(selector); await checkbox.waitForExist(); - if (hideSelfView !== await checkbox.isSelected()) { + if (enable !== await checkbox.isSelected()) { // we show a div with svg and text after the input and those elements grab the click // so we need to click on the parent element - await this.participant.driver.$(`${HIDE_SELF_VIEW_CHECKBOX}//ancestor::div[1]`).click(); + await this.participant.driver.$(`${selector}//ancestor::div[1]`).click(); } } } diff --git a/tests/pageobjects/Toolbar.ts b/tests/pageobjects/Toolbar.ts index 708fe6546e..77deb73718 100644 --- a/tests/pageobjects/Toolbar.ts +++ b/tests/pageobjects/Toolbar.ts @@ -113,7 +113,6 @@ export default class Toolbar extends BasePageObject { await this.getButton(CLOSE_PARTICIPANTS_PANE).click(); } - /** * Clicks Participants pane button. * diff --git a/tests/specs/2way/fakeDialInAudio.spec.ts b/tests/specs/2way/fakeDialInAudio.spec.ts index f87d829e54..b9b785b0e2 100644 --- a/tests/specs/2way/fakeDialInAudio.spec.ts +++ b/tests/specs/2way/fakeDialInAudio.spec.ts @@ -1,7 +1,7 @@ import process from 'node:process'; import { ensureOneParticipant, ensureTwoParticipants } from '../../helpers/participants'; -import { cleanup, waitForAudioFromDialInParticipant } from '../helpers/DialIn'; +import { cleanup, isDialInEnabled, waitForAudioFromDialInParticipant } from '../helpers/DialIn'; describe('Fake Dial-In - ', () => { it('join participant', async () => { @@ -17,8 +17,7 @@ describe('Fake Dial-In - ', () => { await ensureOneParticipant(ctx); // check dial-in is enabled, so skip - if (await ctx.p1.driver.execute(() => Boolean( - config.dialInConfCodeUrl && config.dialInNumbersUrl && config.hosts?.muc))) { + if (await isDialInEnabled(ctx.p1)) { ctx.skipSuiteTests = true; } }); diff --git a/tests/specs/3way/followMe.spec.ts b/tests/specs/3way/followMe.spec.ts new file mode 100644 index 0000000000..4c29a600d0 --- /dev/null +++ b/tests/specs/3way/followMe.spec.ts @@ -0,0 +1,93 @@ +import { ensureThreeParticipants, ensureTwoParticipants } from '../../helpers/participants'; + +describe('Follow Me - ', () => { + it('joining the meeting', async () => { + await ensureTwoParticipants(ctx); + + 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(ctx); + + const { p1, p2, p3 } = ctx; + + await p1.waitForTileViewDisplay(); + + await p1.getToolbar().clickExitTileViewButton(); + + await Promise.all([ + p1.waitForTileViewDisplay(true), + p2.waitForTileViewDisplay(true), + p3.waitForTileViewDisplay(true) + ]); + + await p1.getToolbar().clickEnterTileViewButton(); + + await Promise.all([ + p1.waitForTileViewDisplay(), + p2.waitForTileViewDisplay(), + p3.waitForTileViewDisplay() + ]); + }); + + 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.getLargeVideoId(), + { + 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.getLargeVideoId(), + { + timeout: 5_000, + timeoutMsg: 'The pinned participant is not displayed on stage for p3' + }); + }); +}); diff --git a/tests/specs/alone/dialInAudio.spec.ts b/tests/specs/alone/dialInAudio.spec.ts index 27115f7432..2a6ecf11d5 100644 --- a/tests/specs/alone/dialInAudio.spec.ts +++ b/tests/specs/alone/dialInAudio.spec.ts @@ -2,7 +2,7 @@ import https from 'node:https'; import process from 'node:process'; import { ensureOneParticipant } from '../../helpers/participants'; -import { cleanup, waitForAudioFromDialInParticipant } from '../helpers/DialIn'; +import { cleanup, isDialInEnabled, waitForAudioFromDialInParticipant } from '../helpers/DialIn'; describe('Dial-In - ', () => { it('join participant', async () => { @@ -16,8 +16,7 @@ describe('Dial-In - ', () => { await ensureOneParticipant(ctx); // check dial-in is enabled - if (!await ctx.p1.driver.execute(() => Boolean( - config.dialInConfCodeUrl && config.dialInNumbersUrl && config.hosts?.muc))) { + if (!await isDialInEnabled(ctx.p1)) { ctx.skipSuiteTests = true; } }); diff --git a/tests/specs/alone/invite.spec.ts b/tests/specs/alone/invite.spec.ts new file mode 100644 index 0000000000..f455b9e9c9 --- /dev/null +++ b/tests/specs/alone/invite.spec.ts @@ -0,0 +1,87 @@ +import { ensureOneParticipant } from '../../helpers/participants'; +import { isDialInEnabled } from '../helpers/DialIn'; + +describe('Invite - ', () => { + it('join participant', async () => { + await ensureOneParticipant(ctx); + }); + + it('url displayed', async () => { + const { p1 } = ctx; + const inviteDialog = p1.getInviteDialog(); + + await inviteDialog.open(); + await inviteDialog.waitTillOpen(); + + const driverUrl = await p1.driver.getUrl(); + + expect(driverUrl.includes(await inviteDialog.getMeetingURL())).toBe(true); + + await inviteDialog.clickCloseButton(); + + await inviteDialog.waitTillOpen(true); + }); + + it('dial-in displayed', async () => { + const { p1 } = ctx; + + if (!await isDialInEnabled(p1)) { + return; + } + + const inviteDialog = p1.getInviteDialog(); + + await inviteDialog.open(); + await inviteDialog.waitTillOpen(); + + expect((await inviteDialog.getDialInNumber()).length > 0).toBe(true); + expect((await inviteDialog.getPinNumber()).length > 0).toBe(true); + }); + + it('view more numbers', async () => { + const { p1 } = ctx; + + if (!await isDialInEnabled(p1)) { + return; + } + + const inviteDialog = p1.getInviteDialog(); + + await inviteDialog.open(); + await inviteDialog.waitTillOpen(); + + const windows = await p1.driver.getWindowHandles(); + + expect(windows.length).toBe(1); + + const meetingWindow = windows[0]; + + const displayedNumber = await inviteDialog.getDialInNumber(); + const displayedPin = await inviteDialog.getPinNumber(); + + await inviteDialog.openDialInNumbersPage(); + + const newWindow = (await p1.driver.getWindowHandles()).filter(w => w !== meetingWindow); + + expect(newWindow.length).toBe(1); + + const moreNumbersWindow = newWindow[0]; + + await p1.driver.switchWindow(moreNumbersWindow); + + await browser.pause(5000); + + await p1.driver.$('.dial-in-numbers-list').waitForExist(); + + const conferenceIdMessage = p1.driver.$('//div[contains(@class, "pinLabel")]'); + + expect((await conferenceIdMessage.getText()).replace(/ /g, '').includes(displayedPin)).toBe(true); + + const numbers = p1.driver.$$('.dial-in-number'); + + const nums = await numbers.filter( + async el => (await el.getText()).trim() === displayedNumber); + + expect(nums.length).toBe(1); + }); +}); diff --git a/tests/specs/helpers/DialIn.ts b/tests/specs/helpers/DialIn.ts index 2131544b83..a9d088283d 100644 --- a/tests/specs/helpers/DialIn.ts +++ b/tests/specs/helpers/DialIn.ts @@ -37,3 +37,12 @@ export async function cleanup(participant: Participant) { await participant.getFilmstrip().kickParticipant(jigasiEndpointId); } } + +/** + * Checks if the dial-in is enabled. + * @param participant + */ +export async function isDialInEnabled(participant: Participant) { + return await participant.driver.execute(() => Boolean( + config.dialInConfCodeUrl && config.dialInNumbersUrl && config.hosts?.muc)); +}