diff --git a/tests/env.example b/tests/env.example index 49c16618cd91..d148838be4e0 100644 --- a/tests/env.example +++ b/tests/env.example @@ -24,3 +24,8 @@ #JWT_PRIVATE_KEY_PATH= # The kid to use in the token #JWT_KID= + +# The address of the webhooks proxy used to test the webhooks feature (e.g. wss://your.service/?tenant=sometenant) +#WEBHOOKS_PROXY_URL= +# A shared secret to authenticate the webhook proxy connection +#WEBHOOKS_PROXY_SHARED_SECRET= diff --git a/tests/helpers/WebhookProxy.ts b/tests/helpers/WebhookProxy.ts new file mode 100644 index 000000000000..4fe7b542f83d --- /dev/null +++ b/tests/helpers/WebhookProxy.ts @@ -0,0 +1,128 @@ +import WebSocket from 'ws'; + +/** + * Uses the webhook proxy service to proxy events to the testing clients. + */ +export default class WebhookProxy { + private url; + private secret; + private ws: WebSocket | undefined; + private cache = new Map(); + private listeners = new Map(); + private consumers = new Map(); + + /** + * Initializes the webhook proxy. + * @param url + * @param secret + */ + constructor(url: string, secret: string) { + this.url = url; + this.secret = secret; + } + + /** + * Connects. + */ + connect() { + this.ws = new WebSocket(this.url, { + headers: { + Authorization: this.secret + } + }); + + this.ws.on('error', console.error); + + this.ws.on('open', function open() { + console.log('WebhookProxy connected'); + }); + + this.ws.on('message', (data: any) => { + const msg = JSON.parse(data.toString()); + + if (msg.eventType) { + if (this.consumers.has(msg.eventType)) { + this.consumers.get(msg.eventType)(msg); + this.consumers.delete(msg.eventType); + } else { + this.cache.set(msg.eventType, msg); + } + + if (this.listeners.has(msg.eventType)) { + this.listeners.get(msg.eventType)(msg); + } + } + }); + } + + /** + * Adds event consumer. Consumers receive the event single time and we remove them from the list of consumers. + * @param eventType + * @param callback + */ + addConsumer(eventType: string, callback: (deventata: any) => void) { + if (this.cache.has(eventType)) { + callback(this.cache.get(eventType)); + this.cache.delete(eventType); + + return; + } + + this.consumers.set(eventType, callback); + } + + /** + * Clear any stored event. + */ + clearCache() { + this.cache.clear(); + } + + /** + * Waits for the event to be received. + * @param eventType + * @param timeout + */ + async waitForEvent(eventType: string, timeout = 3000): Promise { + return new Promise((resolve, reject) => { + const waiter = setTimeout(() => { + reject(new Error(`Timeout waiting for event:${eventType}`)); + }, timeout); + + this.addConsumer(eventType, event => { + clearTimeout(waiter); + + resolve(event); + }); + + }); + } + + /** + * Adds a listener for the event type. + * @param eventType + * @param callback + */ + addListener(eventType: string, callback: (data: any) => void) { + this.listeners.set(eventType, callback); + } + + /** + * Adds a listener for the event type. + * @param eventType + */ + removeListener(eventType: string) { + this.listeners.delete(eventType); + } + + /** + * Disconnects the webhook proxy. + */ + disconnect() { + if (this.ws) { + this.ws.close(); + console.log('WebhookProxy disconnected'); + this.ws = undefined; + } + } +} diff --git a/tests/helpers/participants.ts b/tests/helpers/participants.ts index 81207343e16c..1b46652316be 100644 --- a/tests/helpers/participants.ts +++ b/tests/helpers/participants.ts @@ -1,17 +1,30 @@ import fs from 'fs'; import jwt from 'jsonwebtoken'; +import process from 'node:process'; import { v4 as uuidv4 } from 'uuid'; import { Participant } from './Participant'; +import WebhookProxy from './WebhookProxy'; import { IContext } from './types'; /** * Generate a random room name. + * Everytime we generate a name and iframeAPI is enabled and there is a configured + * webhooks proxy we connect to it with the new room name. * * @returns {string} - The random room name. */ function generateRandomRoomName(): string { - return `jitsimeettorture-${crypto.randomUUID()}`; + const roomName = `jitsimeettorture-${crypto.randomUUID()}`; + + if (context.iframeAPI && !context.webhooksProxy + && process.env.WEBHOOKS_PROXY_URL && process.env.WEBHOOKS_PROXY_SHARED_SECRET) { + context.webhooksProxy = new WebhookProxy(`${process.env.WEBHOOKS_PROXY_URL}&room=${roomName}`, + process.env.WEBHOOKS_PROXY_SHARED_SECRET); + context.webhooksProxy.connect(); + } + + return roomName; } /** @@ -21,7 +34,9 @@ function generateRandomRoomName(): string { * @returns {Promise} */ export async function ensureOneParticipant(context: IContext): Promise { - context.roomName = generateRandomRoomName(); + if (!context.roomName) { + context.roomName = generateRandomRoomName(); + } context.p1 = new Participant('participant1'); @@ -35,7 +50,9 @@ export async function ensureOneParticipant(context: IContext): Promise { * @returns {Promise} */ export async function ensureThreeParticipants(context: IContext): Promise { - context.roomName = generateRandomRoomName(); + if (!context.roomName) { + context.roomName = generateRandomRoomName(); + } const p1 = new Participant('participant1'); const p2 = new Participant('participant2'); @@ -201,7 +218,9 @@ function getModeratorToken(displayName: string) { 'room': '*' }; + // @ts-ignore payload.context.user.moderator = true; + // @ts-ignore return jwt.sign(payload, key, headers); } diff --git a/tests/helpers/types.ts b/tests/helpers/types.ts index 68bbdb0150bc..e75c3c9e916e 100644 --- a/tests/helpers/types.ts +++ b/tests/helpers/types.ts @@ -1,6 +1,8 @@ import type { Participant } from './Participant'; +import WebhookProxy from './WebhookProxy'; export type IContext = { + conferenceJid: string; iframeAPI: boolean; jwtKid: string; jwtPrivateKeyPath: string; @@ -9,4 +11,5 @@ export type IContext = { p3: Participant; p4: Participant; roomName: string; + webhooksProxy: WebhookProxy; }; diff --git a/tests/pageobjects/IframeAPI.ts b/tests/pageobjects/IframeAPI.ts index 95e589efe660..12f25f21fdb8 100644 --- a/tests/pageobjects/IframeAPI.ts +++ b/tests/pageobjects/IframeAPI.ts @@ -68,9 +68,9 @@ export default class IframeAPI { */ async executeCommand(command: string, ...args: any[]) { return this.participant.driver.execute( - (commandName, commandArgs) => { - return window.jitsiAPI.executeCommand(commandName, ...commandArgs); - }, command, args); + (commandName, commandArgs) => + window.jitsiAPI.executeCommand(commandName, ...commandArgs) + , command, args); } /** diff --git a/tests/specs/2way/participantsPresence.spec.ts b/tests/specs/2way/iFrameParticipantsPresence.spec.ts similarity index 66% rename from tests/specs/2way/participantsPresence.spec.ts rename to tests/specs/2way/iFrameParticipantsPresence.spec.ts index 2269fc57be21..0f647e737d2e 100644 --- a/tests/specs/2way/participantsPresence.spec.ts +++ b/tests/specs/2way/iFrameParticipantsPresence.spec.ts @@ -1,7 +1,35 @@ import { isEqual } from 'lodash-es'; +import type { Participant } from '../../helpers/Participant'; import { ensureTwoParticipants, parseJid } from '../../helpers/participants'; +/** + * Tests PARTICIPANT_LEFT webhook. + */ +async function checkParticipantLeftHook(p: Participant, reason: string) { + const { webhooksProxy } = context; + + if (webhooksProxy) { + // PARTICIPANT_LEFT webhook + // @ts-ignore + const event: { + data: { + conference: string; + disconnectReason: string; + isBreakout: boolean; + participantId: string; + }; + eventType: string; + } = await context.webhooksProxy.waitForEvent('PARTICIPANT_LEFT'); + + expect('PARTICIPANT_LEFT').toBe(event.eventType); + expect(event.data.conference).toBe(context.conferenceJid); + expect(event.data.disconnectReason).toBe(reason); + expect(event.data.isBreakout).toBe(false); + expect(event.data.participantId).toBe(await p.getEndpointId()); + } +} + describe('Participants presence - ', () => { it('joining the meeting', async () => { context.iframeAPI = true; @@ -9,7 +37,7 @@ describe('Participants presence - ', () => { // ensure 2 participants one moderator and one guest, we will load both with iframeAPI await ensureTwoParticipants(context); - const { p1, p2 } = context; + const { p1, p2, webhooksProxy } = context; // let's populate endpoint ids await Promise.all([ @@ -17,7 +45,6 @@ describe('Participants presence - ', () => { p2.getEndpointId() ]); - // ROOM_CREATED await p1.switchToAPI(); await p2.switchToAPI(); @@ -28,12 +55,27 @@ describe('Participants presence - ', () => { .withContext('Is p2 non-moderator') .toBeFalse(); - // ROLE_CHANGED - expect(await p1.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined(); expect(await p2.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined(); - // USAGE + if (webhooksProxy) { + // USAGE webhook + // @ts-ignore + const event: { + data: [ + { participantId: string; } + ]; + eventType: string; + } = await context.webhooksProxy.waitForEvent('USAGE'); + + expect('USAGE').toBe(event.eventType); + + const p1EpId = await p1.getEndpointId(); + const p2EpId = await p2.getEndpointId(); + + expect(event.data.filter(d => d.participantId === p1EpId + || d.participantId === p2EpId).length).toBe(2); + } // we will use it later // TODO figure out why adding those just before grantModerator and we miss the events @@ -43,7 +85,7 @@ describe('Participants presence - ', () => { it('participants info', async () => { - const { p1, roomName } = context; + const { p1, roomName, webhooksProxy } = context; const roomsInfo = (await p1.getIframeAPI().getRoomsInfo()).rooms[0]; expect(roomsInfo).toBeDefined(); @@ -56,6 +98,8 @@ describe('Participants presence - ', () => { const { node, resource } = parseJid(roomsInfo.jid); + context.conferenceJid = roomsInfo.jid.substring(0, roomsInfo.jid.indexOf('/')); + const p1EpId = await p1.getEndpointId(); expect(node).toBe(roomName); @@ -63,6 +107,22 @@ describe('Participants presence - ', () => { expect(roomsInfo.participants.length).toBe(2); expect(await p1.getIframeAPI().getNumberOfParticipants()).toBe(2); + + if (webhooksProxy) { + // ROOM_CREATED webhook + // @ts-ignore + const event: { + data: { + conference: string; + isBreakout: boolean; + }; + eventType: string; + } = await context.webhooksProxy.waitForEvent('ROOM_CREATED'); + + expect('ROOM_CREATED').toBe(event.eventType); + expect(event.data.conference).toBe(context.conferenceJid); + expect(event.data.isBreakout).toBe(false); + } } ); @@ -85,7 +145,7 @@ describe('Participants presence - ', () => { }); it('grant moderator', async () => { - const { p1, p2 } = context; + const { p1, p2, webhooksProxy } = context; const p2EpId = await p2.getEndpointId(); await p1.getIframeAPI().executeCommand('grantModerator', p2EpId); @@ -105,7 +165,27 @@ describe('Participants presence - ', () => { expect(event2?.id).toBe(p2EpId); expect(event2?.role).toBe('moderator'); - // ROLE_CHANGED + if (webhooksProxy) { + // ROLE_CHANGED webhook + // @ts-ignore + const event: { + data: { + grantedBy: { + participantId: string; + }; + grantedTo: { + participantId: string; + }; + role: string; + }; + eventType: string; + } = await context.webhooksProxy.waitForEvent('ROLE_CHANGED'); + + expect('ROLE_CHANGED').toBe(event.eventType); + expect(event.data.role).toBe('moderator'); + expect(event.data.grantedBy.participantId).toBe(await p1.getEndpointId()); + expect(event.data.grantedTo.participantId).toBe(await p2.getEndpointId()); + } }); it('kick participant', async () => { @@ -139,7 +219,7 @@ describe('Participants presence - ', () => { timeoutMsg: 'participantKickedOut event not received on participant2 side' }); - // PARTICIPANT_LEFT + await checkParticipantLeftHook(p2, 'kicked'); expect(eventP1).toBeDefined(); expect(eventP2).toBeDefined(); @@ -180,15 +260,37 @@ describe('Participants presence - ', () => { }); it('join after kick', async () => { - const { p1 } = context; + const { p1, webhooksProxy } = context; await p1.getIframeAPI().addEventListener('participantJoined'); await p1.getIframeAPI().addEventListener('participantMenuButtonClick'); + webhooksProxy?.clearCache(); + // join again await ensureTwoParticipants(context); - // PARTICIPANT_JOINED + if (webhooksProxy) { + // PARTICIPANT_JOINED webhook + // @ts-ignore + const event: { + data: { + conference: string; + isBreakout: boolean; + moderator: boolean; + name: string; + participantId: string; + }; + eventType: string; + } = await context.webhooksProxy.waitForEvent('PARTICIPANT_JOINED'); + + expect('PARTICIPANT_JOINED').toBe(event.eventType); + expect(event.data.conference).toBe(context.conferenceJid); + expect(event.data.isBreakout).toBe(false); + expect(event.data.moderator).toBe(false); + expect(event.data.name).toBe(await context.p2.getLocalDisplayName()); + expect(event.data.participantId).toBe(await context.p2.getEndpointId()); + } await p1.switchToAPI(); @@ -254,7 +356,7 @@ describe('Participants presence - ', () => { expect(eventConferenceLeftP2).toBeDefined(); expect(eventConferenceLeftP2.roomName).toBe(context.roomName); - // PARTICIPANT_LEFT + await checkParticipantLeftHook(p2, 'left'); const eventReadyToCloseP2 = await p2.driver.waitUntil(async () => await p2.getIframeAPI().getEventResult('readyToClose'), { @@ -266,7 +368,7 @@ describe('Participants presence - ', () => { }); it('dispose conference', async () => { - const { p1 } = context; + const { p1, webhooksProxy } = context; await p1.switchToAPI(); @@ -275,9 +377,6 @@ describe('Participants presence - ', () => { await p1.getIframeAPI().executeCommand('hangup'); - // PARTICIPANT_LEFT - // ROOM_DESTROYED - const eventConferenceLeft = await p1.driver.waitUntil(async () => await p1.getIframeAPI().getEventResult('videoConferenceLeft'), { timeout: 2000, @@ -287,7 +386,22 @@ describe('Participants presence - ', () => { expect(eventConferenceLeft).toBeDefined(); expect(eventConferenceLeft.roomName).toBe(context.roomName); - // PARTICIPANT_LEFT + await checkParticipantLeftHook(p1, 'left'); + if (webhooksProxy) { + // ROOM_DESTROYED webhook + // @ts-ignore + const event: { + data: { + conference: string; + isBreakout: boolean; + }; + eventType: string; + } = await context.webhooksProxy.waitForEvent('ROOM_DESTROYED'); + + expect('ROOM_DESTROYED').toBe(event.eventType); + expect(event.data.conference).toBe(context.conferenceJid); + expect(event.data.isBreakout).toBe(false); + } const eventReadyToClose = await p1.driver.waitUntil(async () => await p1.getIframeAPI().getEventResult('readyToClose'), { diff --git a/tests/wdio.conf.ts b/tests/wdio.conf.ts index ae04a784c74d..22967dd4a1a7 100644 --- a/tests/wdio.conf.ts +++ b/tests/wdio.conf.ts @@ -189,6 +189,12 @@ export const config: WebdriverIO.MultiremoteConfig = { globalAny.context.jwtKid = process.env.JWT_KID; }, + after() { + if (context.webhooksProxy) { + context.webhooksProxy.disconnect(); + } + }, + /** * Gets executed before the suite starts (in Mocha/Jasmine only). * diff --git a/tests/wdio.firefox.conf.ts b/tests/wdio.firefox.conf.ts index 5db7a957e8ac..56a62a6ec13c 100644 --- a/tests/wdio.firefox.conf.ts +++ b/tests/wdio.firefox.conf.ts @@ -21,7 +21,7 @@ if (process.env.HEADLESS === 'true') { export const config = deepmerge(defaultConfig, { 'wdio:exclude': [ - 'specs/2way/participantsPresence.spec.ts', // FF does not support uploading files (uploadFile) + 'specs/2way/iFrameParticipantsPresence.spec.ts', // FF does not support uploading files (uploadFile) 'specs/3way/activeSpeaker.spec.ts' // FF does not support setting a file as mic input ], capabilities: {