From b055ef033333bca933618d7681ac2a1153b6740c Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Thu, 6 Nov 2025 11:11:03 +0100 Subject: [PATCH] update file exclusion patterns in workspace settings webcomponent: Added missing and necessary js file Update .gitignore to specify backend public directory exclusion webcomponent: Add error handling for invalid base URL in OpenViduMeet component webcomponent: Update Jest configuration for improved testing setup webcomponent: Enhance iframe attribute tests and add support for optional query parameters webcomponent: Refactor documentation copying in build_webcomponent_doc function for improved readability and add absolute path resolution Add E2EE_KEY property to WebComponentProperty enum for end-to-end encryption support meet.sh: Enhance build_rest_api_doc function with output file handling and user confirmation for overwriting frontend: replace removeRoomSecretGuard with removeQueryParamsGuard for enhanced query parameter management frontend: add E2EE key handling in room service and update query params guard Updated pnpm-lock.yaml Enables end-to-end encryption (E2EE) Adds E2EE functionality to meeting rooms. Significant changes: - Allows encryption of the participant name - Introduces setting and getting E2EE keys - Ensures recording is disabled when encryption is enabled webcomponent: Added e2e test for checking the e2ee funcionality frontend: Sanitize participant name before request for a token fix: clean up formatting in openvidu-meet.code-workspace --- .gitignore | 2 +- .../lib/guards/extract-query-params.guard.ts | 21 +- .../src/lib/guards/index.ts | 2 +- .../lib/guards/remove-query-params.guard.ts | 49 ++ .../src/lib/guards/remove-secret.guard.ts | 25 - .../room-config/room-config.component.html | 3 +- .../src/lib/routes/base-routes.ts | 11 +- .../meeting/meeting-event-handler.service.ts | 70 +-- .../services/meeting/meeting-lobby.service.ts | 31 +- .../src/lib/services/navigation.service.ts | 19 +- .../src/lib/services/participant.service.ts | 23 +- .../src/lib/services/room.service.ts | 9 + .../webcomponent/.vscode/settings.json | 5 +- meet-ce/frontend/webcomponent/jest.config.mjs | 28 +- .../src/components/OpenViduMeet.ts | 27 +- .../webcomponent/tests/e2e/e2ee-ui.test.ts | 473 ++++++++++++++++- .../tests/helpers/function-helpers.ts | 75 ++- .../tests/unit/attributes.test.ts | 409 +++++++++++++-- .../src/webcomponent/properties.model.ts | 6 + meet.sh | 77 ++- openvidu-meet.code-workspace | 20 +- pnpm-lock.yaml | 494 ++++++++++++++++-- testapp/public/js/webcomponent.js | 219 ++++++++ 23 files changed, 1867 insertions(+), 231 deletions(-) create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/guards/remove-query-params.guard.ts delete mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/guards/remove-secret.guard.ts create mode 100644 testapp/public/js/webcomponent.js diff --git a/.gitignore b/.gitignore index 130871f7..eaf43f60 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,7 @@ pnpm-debug.log* **/**/test-results -**/**/public/ +**/backend/public/ **/*/coverage **/**/test-results diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts index 0d461e0c..25a02d50 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts @@ -1,13 +1,7 @@ import { inject } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivateFn } from '@angular/router'; import { ErrorReason } from '../models'; -import { - AppDataService, - NavigationService, - ParticipantService, - RoomService, - SessionStorageService -} from '../services'; +import { AppDataService, NavigationService, ParticipantService, RoomService, SessionStorageService } from '../services'; import { WebComponentProperty } from '@openvidu-meet/typings'; export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => { @@ -16,7 +10,14 @@ export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRoute const participantService = inject(ParticipantService); const sessionStorageService = inject(SessionStorageService); - const { roomId, secret: querySecret, participantName, leaveRedirectUrl, showOnlyRecordings } = extractParams(route); + const { + roomId, + secret: querySecret, + participantName, + leaveRedirectUrl, + showOnlyRecordings, + e2eeKey + } = extractParams(route); const secret = querySecret || sessionStorageService.getRoomSecret(); // Handle leave redirect URL logic @@ -29,6 +30,7 @@ export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRoute roomService.setRoomId(roomId); roomService.setRoomSecret(secret); + roomService.setE2EEKey(e2eeKey); if (participantName) { participantService.setParticipantName(participantName); @@ -66,7 +68,8 @@ const extractParams = ({ params, queryParams }: ActivatedRouteSnapshot) => ({ secret: queryParams['secret'] as string, participantName: queryParams[WebComponentProperty.PARTICIPANT_NAME] as string, leaveRedirectUrl: queryParams[WebComponentProperty.LEAVE_REDIRECT_URL] as string, - showOnlyRecordings: (queryParams[WebComponentProperty.SHOW_ONLY_RECORDINGS] as string) || 'false' + showOnlyRecordings: (queryParams[WebComponentProperty.SHOW_ONLY_RECORDINGS] as string) || 'false', + e2eeKey: queryParams[WebComponentProperty.E2EE_KEY] as string }); /** diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/guards/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/guards/index.ts index 5d5b2404..435d7e29 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/guards/index.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/guards/index.ts @@ -1,5 +1,5 @@ export * from './auth.guard'; export * from './extract-query-params.guard'; -export * from './remove-secret.guard'; +export * from './remove-query-params.guard'; export * from './run-serially.guard'; export * from './validate-access.guard'; diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/guards/remove-query-params.guard.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/guards/remove-query-params.guard.ts new file mode 100644 index 00000000..61524f79 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/guards/remove-query-params.guard.ts @@ -0,0 +1,49 @@ +import { inject } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivateFn, NavigationEnd, Router } from '@angular/router'; +import { filter, take } from 'rxjs'; +import { NavigationService } from '../services'; + +/** + * Guard that removes specified query parameters from the URL after the navigation completes. + * + * @param params - Array of query parameter names to remove from the URL + * @returns A guard function that schedules removal of the specified query parameters after navigation + * + */ +export const removeQueryParamsGuard = (params: string[]): CanActivateFn => { + return (route: ActivatedRouteSnapshot) => { + const router = inject(Router); + const navigationService = inject(NavigationService); + + // Only proceed if there are params to remove + if (!params || params.length === 0) { + return true; + } + + // Check if any of the specified params exist in the current query params + const hasParamsToRemove = params.some((param) => route.queryParams[param] !== undefined); + + if (!hasParamsToRemove) { + // No params to remove, continue navigation immediately + return true; + } + + // Schedule param removal AFTER navigation completes + // This prevents conflicts with the ongoing navigation + router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + take(1) + ) + .subscribe(async () => { + try { + await navigationService.removeQueryParamsFromUrl(route.queryParams, params); + } catch (error) { + console.error('Error removing query params:', error); + } + }); + + // Allow the current navigation to proceed + return true; + }; +}; diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/guards/remove-secret.guard.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/guards/remove-secret.guard.ts deleted file mode 100644 index b7d8bcf3..00000000 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/guards/remove-secret.guard.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { inject } from '@angular/core'; -import { CanActivateFn, NavigationEnd, Router } from '@angular/router'; -import { NavigationService } from '../services'; -import { filter, take } from 'rxjs'; - -/** - * Guard that intercepts navigation to remove the 'secret' query parameter from the URL - * that determine the role of a participant when joining a room or accessing its recordings, - * in order to enhance security. - */ -export const removeRoomSecretGuard: CanActivateFn = (route, _state) => { - const router = inject(Router); - const navigationService = inject(NavigationService); - - router.events - .pipe( - filter((event) => event instanceof NavigationEnd), - take(1) - ) - .subscribe(async () => { - await navigationService.removeQueryParamFromUrl(route.queryParams, 'secret'); - }); - - return true; -}; diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.html index d48d2669..d117c2eb 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-config/room-config.component.html @@ -52,8 +52,7 @@ message but will be unable to see or hear others. -
  • Recording is unavailable while encryption is enabled.
  • -
  • Chat messages are not protected by end-to-end encryption.
  • +
  • Recording is unavailable while encryption is enabled.
  • diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/routes/base-routes.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/routes/base-routes.ts index d9b42d91..386d4d01 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/routes/base-routes.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/routes/base-routes.ts @@ -6,7 +6,7 @@ import { checkUserNotAuthenticatedGuard, extractRecordingQueryParamsGuard, extractRoomQueryParamsGuard, - removeRoomSecretGuard, + removeQueryParamsGuard, runGuardsSerially, validateRecordingAccessGuard, validateRoomAccessGuard @@ -27,6 +27,7 @@ import { ViewRecordingComponent, ConfigComponent } from '../pages'; +import { WebComponentProperty } from '@openvidu-meet/typings'; export const baseRoutes: Routes = [ { @@ -40,9 +41,9 @@ export const baseRoutes: Routes = [ canActivate: [ runGuardsSerially( extractRoomQueryParamsGuard, + removeQueryParamsGuard(['secret', WebComponentProperty.E2EE_KEY]), checkParticipantRoleAndAuthGuard, - validateRoomAccessGuard, - removeRoomSecretGuard + validateRoomAccessGuard ) ] }, @@ -52,9 +53,9 @@ export const baseRoutes: Routes = [ canActivate: [ runGuardsSerially( extractRecordingQueryParamsGuard, + removeQueryParamsGuard(['secret', WebComponentProperty.E2EE_KEY]), checkParticipantRoleAndAuthGuard, - validateRecordingAccessGuard, - removeRoomSecretGuard + validateRecordingAccessGuard ) ] }, diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-event-handler.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-event-handler.service.ts index dd56aae6..74a2e3c3 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-event-handler.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-event-handler.service.ts @@ -92,38 +92,48 @@ export class MeetingEventHandlerService { ): void { room.on( RoomEvent.DataReceived, - async ( - payload: Uint8Array, - _participant?: RemoteParticipant, - _kind?: DataPacket_Kind, - topic?: string - ) => { - const event = JSON.parse(new TextDecoder().decode(payload)); + async (payload: Uint8Array, _participant?: RemoteParticipant, _kind?: DataPacket_Kind, topic?: string) => { + // Only process topics that this handler is responsible for + const relevantTopics = [ + 'recordingStopped', + MeetSignalType.MEET_ROOM_CONFIG_UPDATED, + MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED + ]; - switch (topic) { - case 'recordingStopped': - await this.handleRecordingStopped( - context.roomId, - context.roomSecret, - context.onHasRecordingsChanged - ); - break; + if (!topic || !relevantTopics.includes(topic)) { + return; + } - case MeetSignalType.MEET_ROOM_CONFIG_UPDATED: - await this.handleRoomConfigUpdated(event, context.roomId, context.roomSecret); - break; + try { + const event = JSON.parse(new TextDecoder().decode(payload)); - case MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED: - await this.handleParticipantRoleUpdated( - event, - context.roomId, - context.participantName, - context.localParticipant, - context.remoteParticipants, - context.onRoomSecretChanged, - context.onParticipantRoleUpdated - ); - break; + switch (topic) { + case 'recordingStopped': + await this.handleRecordingStopped( + context.roomId, + context.roomSecret, + context.onHasRecordingsChanged + ); + break; + + case MeetSignalType.MEET_ROOM_CONFIG_UPDATED: + await this.handleRoomConfigUpdated(event, context.roomId, context.roomSecret); + break; + + case MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED: + await this.handleParticipantRoleUpdated( + event, + context.roomId, + context.participantName, + context.localParticipant, + context.remoteParticipants, + context.onRoomSecretChanged, + context.onParticipantRoleUpdated + ); + break; + } + } catch (error) { + console.warn(`Failed to parse data message for topic: ${topic}`, error); } } ); @@ -203,7 +213,7 @@ export class MeetingEventHandlerService { if (error.status === 503) { console.error( 'No egress service available. Check CPU usage or Media Node capacity. ' + - 'By default, a recording uses 2 CPUs per room.' + 'By default, a recording uses 2 CPUs per room.' ); } else { console.error('Error starting recording:', error); diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-lobby.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-lobby.service.ts index bede6ce6..f16d9918 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-lobby.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-lobby.service.ts @@ -72,6 +72,10 @@ export class MeetingLobbyService { return value.name.trim(); } + set e2eeKey(key: string) { + this.state.participantForm.get('e2eeKey')?.setValue(key); + } + get e2eeKey(): string { const { valid, value } = this.state.participantForm; if (!valid || !value.e2eeKey?.trim()) { @@ -93,6 +97,12 @@ export class MeetingLobbyService { // If E2EE is enabled, require e2eeKey if (this.state.isE2EEEnabled) { this.state.participantForm.get('e2eeKey')?.setValidators([Validators.required]); + this.e2eeKey = this.roomService.getE2EEKey(); + + if (this.e2eeKey) { + // when e2eeKey is already set (e.g., from URL or webcomponent), populate and disable field + this.state.participantForm.get('e2eeKey')?.disable(); + } this.state.participantForm.get('e2eeKey')?.updateValueAndValidity(); } @@ -145,10 +155,16 @@ export class MeetingLobbyService { } async submitAccess(): Promise { - if (!this.participantName) { + const sanitized = this.participantName + .replace(/[^a-zA-Z0-9 _-]/g, '') // remove invalid chars + .replace(/\s+/g, ' ') // normalize spaces + .trim(); // remove leading/trailing spaces + + if (!sanitized) { console.error('Participant form is invalid. Cannot access meeting.'); throw new Error('Participant form is invalid'); } + this.participantName = sanitized; // For E2EE rooms, validate passkey if (this.state.isE2EEEnabled && !this.e2eeKey) { @@ -243,11 +259,14 @@ export class MeetingLobbyService { */ protected async generateParticipantToken() { try { - this.state.participantToken = await this.participantService.generateToken({ - roomId: this.state.roomId, - secret: this.state.roomSecret, - participantName: this.participantName - }); + this.state.participantToken = await this.participantService.generateToken( + { + roomId: this.state.roomId, + secret: this.state.roomSecret, + participantName: this.participantName + }, + this.e2eeKey + ); this.participantName = this.participantService.getParticipantName()!; } catch (error: any) { console.error('Error generating participant token:', error); diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/navigation.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/navigation.service.ts index e0f55712..2c905b7c 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/navigation.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/navigation.service.ts @@ -177,8 +177,25 @@ export class NavigationService { * @param param - The parameter to remove */ async removeQueryParamFromUrl(queryParams: Params, param: string): Promise { + await this.removeQueryParamsFromUrl(queryParams, [param]); + } + + /** + * Removes multiple query parameters from the URL in a single navigation operation. + * This is more efficient than removing params one by one, as it only triggers one navigation. + * + * @param queryParams - The current query parameters + * @param params - Array of parameter names to remove + */ + async removeQueryParamsFromUrl(queryParams: Params, params: string[]): Promise { + if (!params || params.length === 0) { + return; + } + const updatedParams = { ...queryParams }; - delete updatedParams[param]; + params.forEach((param) => { + delete updatedParams[param]; + }); await this.router.navigate([], { queryParams: updatedParams, diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/participant.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/participant.service.ts index b46dff7e..d4f2bc54 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/participant.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/participant.service.ts @@ -8,7 +8,7 @@ import { ParticipantRole } from '@openvidu-meet/typings'; import { getValidDecodedToken } from '../utils'; -import { LoggerService } from 'openvidu-components-angular'; +import { E2eeService, LoggerService } from 'openvidu-components-angular'; @Injectable({ providedIn: 'root' @@ -29,7 +29,8 @@ export class ParticipantService { protected httpService: HttpService, protected featureConfService: FeatureConfigurationService, protected globalConfigService: GlobalConfigService, - protected tokenStorageService: TokenStorageService + protected tokenStorageService: TokenStorageService, + protected e2eeService: E2eeService ) { this.log = this.loggerService.get('OpenVidu Meet - ParticipantTokenService'); } @@ -53,8 +54,15 @@ export class ParticipantService { * @param participantOptions - The options for the participant, including room ID, participant name, and secret * @return A promise that resolves to the participant token */ - async generateToken(participantOptions: ParticipantOptions): Promise { + async generateToken(participantOptions: ParticipantOptions, e2EEKey = ''): Promise { const path = `${this.PARTICIPANTS_API}/token`; + + if (participantOptions.participantName && !!e2EEKey) { + // Asign E2EE key and encrypt participant name + await this.e2eeService.setE2EEKey(e2EEKey); + participantOptions.participantName = await this.e2eeService.encrypt(participantOptions.participantName); + } + const { token } = await this.httpService.postRequest<{ token: string }>(path, participantOptions); // Store token in sessionStorage for header mode @@ -63,7 +71,7 @@ export class ParticipantService { this.tokenStorageService.setParticipantToken(token); } - this.updateParticipantTokenInfo(token); + await this.updateParticipantTokenInfo(token); return token; } @@ -83,7 +91,7 @@ export class ParticipantService { this.tokenStorageService.setParticipantToken(token); } - this.updateParticipantTokenInfo(token); + await this.updateParticipantTokenInfo(token); return token; } @@ -93,13 +101,14 @@ export class ParticipantService { * @param token - The JWT token to set. * @throws Error if the token is invalid or expired. */ - protected updateParticipantTokenInfo(token: string): void { + protected async updateParticipantTokenInfo(token: string): Promise { try { const decodedToken = getValidDecodedToken(token); const metadata = decodedToken.metadata as MeetTokenMetadata; if (decodedToken.sub && decodedToken.name) { - this.setParticipantName(decodedToken.name); + const decryptedName = await this.e2eeService.decryptOrMask(decodedToken.name); + this.setParticipantName(decryptedName); this.participantIdentity = decodedToken.sub; } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/room.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/room.service.ts index ce2da058..6e9d2787 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/room.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/room.service.ts @@ -22,6 +22,7 @@ export class RoomService { protected roomId: string = ''; protected roomSecret: string = ''; + protected e2eeKey: string = ''; protected log; @@ -50,6 +51,14 @@ export class RoomService { } } + setE2EEKey(e2eeKey: string) { + this.e2eeKey = e2eeKey; + } + + getE2EEKey(): string { + return this.e2eeKey; + } + getRoomSecret(): string { return this.roomSecret; } diff --git a/meet-ce/frontend/webcomponent/.vscode/settings.json b/meet-ce/frontend/webcomponent/.vscode/settings.json index 2c2f71ed..8470f353 100644 --- a/meet-ce/frontend/webcomponent/.vscode/settings.json +++ b/meet-ce/frontend/webcomponent/.vscode/settings.json @@ -1,8 +1,5 @@ { - "jest.jestCommandLine": "node --experimental-vm-modules ../../../node_modules/.bin/jest --config jest.config.mjs", + "jest.jestCommandLine": "pnpm run test:unit", "jest.rootPath": ".", - "jest.nodeEnv": { - "NODE_OPTIONS": "--experimental-vm-modules" - }, "jest.runMode": "on-demand" } diff --git a/meet-ce/frontend/webcomponent/jest.config.mjs b/meet-ce/frontend/webcomponent/jest.config.mjs index 378901d0..19e6a9af 100644 --- a/meet-ce/frontend/webcomponent/jest.config.mjs +++ b/meet-ce/frontend/webcomponent/jest.config.mjs @@ -1,25 +1,21 @@ -import { createDefaultEsmPreset } from 'ts-jest' - -/** @type {import('ts-jest').JestConfigWithTsJest} */ -const jestConfig = { +/** @type {import('jest').Config} */ +const config = { displayName: 'webcomponent', - ...createDefaultEsmPreset({ - tsconfig: 'tsconfig.json' - }), - // Set the root directory to the webcomponent folder - rootDir: './', + preset: 'ts-jest/presets/default-esm', + extensionsToTreatAsEsm: ['.ts'], + globals: { + 'ts-jest': { + useESM: true, + tsconfig: 'tsconfig.json' + } + }, resolver: 'ts-jest-resolver', testEnvironment: 'jsdom', testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'], moduleFileExtensions: ['js', 'ts', 'json', 'node'], testPathIgnorePatterns: ['/node_modules/', '/dist/', '/tests/e2e/'], transform: { - '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }] - }, - globals: { - 'ts-jest': { - tsconfig: 'tsconfig.json' - } + '^.+\\.tsx?$': ['ts-jest', { useESM: true }] }, moduleNameMapper: { '^@openvidu-meet/typings$': '/../../typings/src/index.ts', @@ -27,4 +23,4 @@ const jestConfig = { } } -export default jestConfig +export default config diff --git a/meet-ce/frontend/webcomponent/src/components/OpenViduMeet.ts b/meet-ce/frontend/webcomponent/src/components/OpenViduMeet.ts index 981fd3fd..4a0361ea 100644 --- a/meet-ce/frontend/webcomponent/src/components/OpenViduMeet.ts +++ b/meet-ce/frontend/webcomponent/src/components/OpenViduMeet.ts @@ -143,19 +143,24 @@ export class OpenViduMeet extends HTMLElement { return; } - const url = new URL(baseUrl); - this.targetIframeOrigin = url.origin; - this.commandsManager.setTargetOrigin(this.targetIframeOrigin); - this.eventsManager.setTargetOrigin(this.targetIframeOrigin); + try { + const url = new URL(baseUrl); + this.targetIframeOrigin = url.origin; + this.commandsManager.setTargetOrigin(this.targetIframeOrigin); + this.eventsManager.setTargetOrigin(this.targetIframeOrigin); - // Update query params - Array.from(this.attributes).forEach((attr) => { - if (attr.name !== WebComponentProperty.ROOM_URL && attr.name !== WebComponentProperty.RECORDING_URL) { - url.searchParams.set(attr.name, attr.value); - } - }); + // Update query params + Array.from(this.attributes).forEach((attr) => { + if (attr.name !== WebComponentProperty.ROOM_URL && attr.name !== WebComponentProperty.RECORDING_URL) { + url.searchParams.set(attr.name, attr.value); + } + }); - this.iframe.src = url.toString(); + this.iframe.src = url.toString(); + } catch (error) { + console.error(`Invalid URL provided: ${baseUrl}`, error); + alert(`Invalid URL provided: ${baseUrl}`); + } } /** diff --git a/meet-ce/frontend/webcomponent/tests/e2e/e2ee-ui.test.ts b/meet-ce/frontend/webcomponent/tests/e2e/e2ee-ui.test.ts index e9f37700..522d571f 100644 --- a/meet-ce/frontend/webcomponent/tests/e2e/e2ee-ui.test.ts +++ b/meet-ce/frontend/webcomponent/tests/e2e/e2ee-ui.test.ts @@ -124,10 +124,10 @@ test.describe('E2EE UI Tests', () => { }); // ========================================== - // E2EE SESSION TESTS + // E2EE MEETING TESTS // ========================================== - test.describe('E2EE in Session', () => { + test.describe('E2EE in Meeting', () => { test.afterEach(async ({ page }) => { try { await leaveRoom(page); @@ -152,7 +152,6 @@ test.describe('E2EE UI Tests', () => { const page2 = await context.newPage(); // Participant 1 joins with E2EE key - await page.goto(MEET_TESTAPP_URL); await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId); await page.click('#join-as-speaker'); @@ -178,7 +177,6 @@ test.describe('E2EE UI Tests', () => { // Participant 2 joins with same E2EE key const participant2Name = `P2-${Math.random().toString(36).substring(2, 9)}`; - await page2.goto(MEET_TESTAPP_URL); await prepareForJoiningRoom(page2, MEET_TESTAPP_URL, roomId); await page2.click('#join-as-speaker'); @@ -222,6 +220,16 @@ test.describe('E2EE UI Tests', () => { }); await expect(encryptionError2).toBeHidden(); + // Expect video to be flowing (by checking the video element has video tracks) + const videoElements = await waitForElementInIframe(page, '.OV_video-element', { + state: 'visible', + all: true + }); + for (const videoElement of videoElements) { + const videoTracks = await videoElement.evaluate((el) => (el as any).srcObject?.getVideoTracks()); + expect(videoTracks.length).toBeGreaterThan(0); + } + // Cleanup participant 2 await leaveRoom(page2); await page2.close(); @@ -342,6 +350,463 @@ test.describe('E2EE UI Tests', () => { await Promise.all([leaveRoom(page2), leaveRoom(page3)]); await Promise.all([page2.close(), page3.close()]); }); + + test('should decrypt participant names and chat messages with correct E2EE key', async ({ page, context }) => { + // Enable E2EE + await updateRoomConfig(roomId, { + chat: { enabled: true }, + recording: { enabled: false, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, + virtualBackground: { enabled: true }, + e2ee: { enabled: true } + }); + + const e2eeKey = 'shared-encryption-key-456'; + const participant1Name = `Alice-${Math.random().toString(36).substring(2, 9)}`; + const participant2Name = `Bob-${Math.random().toString(36).substring(2, 9)}`; + + // Participant 1 joins with E2EE key + await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId); + await page.click('#join-as-speaker'); + + await waitForElementInIframe(page, '#participant-name-input', { state: 'visible' }); + await interactWithElementInIframe(page, '#participant-name-input', { + action: 'fill', + value: participant1Name + }); + + await interactWithElementInIframe(page, '#participant-e2eekey-input', { + action: 'fill', + value: e2eeKey + }); + + await interactWithElementInIframe(page, '#participant-name-submit', { action: 'click' }); + await waitForElementInIframe(page, 'ov-pre-join', { state: 'visible' }); + await interactWithElementInIframe(page, '#join-button', { action: 'click' }); + await waitForElementInIframe(page, 'ov-session', { state: 'visible' }); + + // Participant 2 joins with same E2EE key + const page2 = await context.newPage(); + await prepareForJoiningRoom(page2, MEET_TESTAPP_URL, roomId); + await page2.click('#join-as-speaker'); + + await waitForElementInIframe(page2, '#participant-name-input', { state: 'visible' }); + await interactWithElementInIframe(page2, '#participant-name-input', { + action: 'fill', + value: participant2Name + }); + + await interactWithElementInIframe(page2, '#participant-e2eekey-input', { + action: 'fill', + value: e2eeKey + }); + + await interactWithElementInIframe(page2, '#participant-name-submit', { action: 'click' }); + await waitForElementInIframe(page2, 'ov-pre-join', { state: 'visible' }); + await interactWithElementInIframe(page2, '#join-button', { action: 'click' }); + await waitForElementInIframe(page2, 'ov-session', { state: 'visible' }); + + // Wait for participants to connect + await page.waitForTimeout(2000); + + // ===== CHECK PARTICIPANT NAMES IN VIDEO GRID ===== + // Participant 1 should see Participant 2's name decrypted + const participantNameElements = await Promise.all([ + waitForElementInIframe(page, '#participant-name', { + state: 'attached', + all: true + }), + waitForElementInIframe(page2, '#participant-name', { + state: 'attached', + all: true + }) + ]); + + for (const participantNameElement of participantNameElements.flat()) { + const name = await participantNameElement.evaluate((el) => el.textContent); + expect(name.includes(participant1Name) || name.includes(participant2Name)).toBeTruthy(); + expect(name).not.toContain('*'); + } + + // ===== CHECK NAMES IN PARTICIPANTS PANEL ===== + // Open participants panel + await Promise.all([ + interactWithElementInIframe(page, '#participants-panel-btn', { action: 'click' }), + interactWithElementInIframe(page2, '#participants-panel-btn', { action: 'click' }) + ]); + await Promise.all([ + waitForElementInIframe(page, 'ov-participants-panel', { state: 'visible' }), + waitForElementInIframe(page2, 'ov-participants-panel', { state: 'visible' }) + ]); + // Check that both names are visible and decrypted in the panel + const participantsPanelNames = await Promise.all([ + waitForElementInIframe(page, '.participant-item-name span', { + state: 'attached', + all: true + }), + waitForElementInIframe(page2, '.participant-item-name span', { + state: 'attached', + all: true + }) + ]); + + for (const participantPanelName of participantsPanelNames.flat()) { + const name = await participantPanelName.evaluate((el) => el.textContent); + expect(name.includes(participant1Name) || name.includes(participant2Name)).toBeTruthy(); + expect(name).not.toContain('*'); + } + + // Close participants panel + await Promise.all([ + interactWithElementInIframe(page, '#participants-panel-btn', { action: 'click' }), + interactWithElementInIframe(page2, '#participants-panel-btn', { action: 'click' }) + ]); + await Promise.all([ + waitForElementInIframe(page, 'ov-participants-panel', { state: 'hidden' }), + waitForElementInIframe(page2, 'ov-participants-panel', { state: 'hidden' }) + ]); + + // ===== CHECK OWN NAME IN SETTINGS PANEL ===== + // Open settings panel + await Promise.all([openMoreOptionsMenu(page), openMoreOptionsMenu(page2)]); + await Promise.all([ + interactWithElementInIframe(page, '#toolbar-settings-btn', { action: 'click' }), + interactWithElementInIframe(page2, '#toolbar-settings-btn', { action: 'click' }) + ]); + await Promise.all([ + waitForElementInIframe(page, 'ov-settings-panel', { state: 'visible' }), + waitForElementInIframe(page2, 'ov-settings-panel', { state: 'visible' }) + ]); + + // Check that own name is visible and decrypted + const ownNameInputs = await Promise.all([ + waitForElementInIframe(page, '#participant-name-input', { + state: 'visible' + }), + waitForElementInIframe(page2, '#participant-name-input', { state: 'visible' }) + ]); + + const ownName1 = await ownNameInputs[0].evaluate((el: HTMLInputElement) => el.value); + const ownName2 = await ownNameInputs[1].evaluate((el: HTMLInputElement) => el.value); + expect(ownName1).toBe(participant1Name); + expect(ownName1).not.toContain('*'); + expect(ownName2).toBe(participant2Name); + expect(ownName2).not.toContain('*'); + + // Close settings panel + await Promise.all([ + interactWithElementInIframe(page, '.panel-close-button', { action: 'click' }), + interactWithElementInIframe(page2, '.panel-close-button', { action: 'click' }) + ]); + await Promise.all([ + waitForElementInIframe(page, 'ov-settings-panel', { state: 'hidden' }), + waitForElementInIframe(page2, 'ov-settings-panel', { state: 'hidden' }) + ]); + await Promise.all([closeMoreOptionsMenu(page), closeMoreOptionsMenu(page2)]); + + // ===== CHECK CHAT MESSAGES ===== + // Open chat + await Promise.all([ + interactWithElementInIframe(page, '#chat-panel-btn', { action: 'click' }), + interactWithElementInIframe(page2, '#chat-panel-btn', { action: 'click' }) + ]); + await Promise.all([ + waitForElementInIframe(page, 'ov-chat-panel', { state: 'visible' }), + waitForElementInIframe(page2, 'ov-chat-panel', { state: 'visible' }) + ]); + + // ===== MESSAGE: PARTICIPANT 1 → PARTICIPANT 2 ===== + const testMessage1 = `Hello from ${participant1Name}!`; + await Promise.all([ + interactWithElementInIframe(page, '#chat-input', { action: 'fill', value: testMessage1 }), + waitForElementInIframe(page2, 'ov-chat-panel', { state: 'visible' }) + ]); + + await interactWithElementInIframe(page, '#send-btn', { action: 'click' }); + + // Wait for message to be sent + await page.waitForTimeout(1000); + + // Open chat on page 2 + const chatMessages2 = await waitForElementInIframe(page2, '.chat-message', { state: 'visible' }); + + // Verify message content + const messageText2 = await chatMessages2.evaluate((el) => el.textContent || ''); + expect(messageText2).toContain(testMessage1); + expect(messageText2).not.toContain('*'); + + // ===== MESSAGE: PARTICIPANT 2 → PARTICIPANT 1 ===== + const testMessage2 = `Hi from ${participant2Name}!`; + + // Send message in page2 iframe + await interactWithElementInIframe(page2, '#chat-input', { action: 'fill', value: testMessage2 }); + await interactWithElementInIframe(page2, '#send-btn', { action: 'click' }); + + // Wait briefly for message delivery + await page.waitForTimeout(1000); + + // Wait for message on participant 1’s side + const chatMessages1 = await waitForElementInIframe(page, '.chat-message', { state: 'visible' }); + + // Collect all chat messages in the chat panel + const allMessages1 = await chatMessages1.evaluate((el) => + Array.from(el.closest('ov-chat-panel')?.querySelectorAll('.chat-message') || []).map( + (e) => e.textContent || '' + ) + ); + + // Verify received message + expect(allMessages1.join(' ')).toContain(testMessage2); + expect(allMessages1.join(' ')).not.toContain('*'); + + // Cleanup + await leaveRoom(page2); + await page2.close(); + }); + + test('should show masked names and unreadable messages for participant with wrong E2EE key', async ({ + page, + context + }) => { + // Enable E2EE + await updateRoomConfig(roomId, { + chat: { enabled: true }, + recording: { enabled: false, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER }, + virtualBackground: { enabled: true }, + e2ee: { enabled: true } + }); + + const correctKey = 'correct-shared-key-789'; + const wrongKey = 'wrong-key-999'; + const participant1Name = `Charlie-${Math.random().toString(36).substring(2, 9)}`; + const participant2Name = `David-${Math.random().toString(36).substring(2, 9)}`; + const participant3Name = `Eve-${Math.random().toString(36).substring(2, 9)}`; + const [page2, page3] = await Promise.all([context.newPage(), context.newPage()]); + + // Prepare for all participants to join the room + await Promise.all([ + prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId), + prepareForJoiningRoom(page2, MEET_TESTAPP_URL, roomId), + prepareForJoiningRoom(page3, MEET_TESTAPP_URL, roomId) + ]); + + // Join as speaker in all pages + await Promise.all([ + page.click('#join-as-speaker'), + page2.click('#join-as-speaker'), + page3.click('#join-as-speaker') + ]); + + // Wait for name and E2EE key inputs to be visible in all pages + await Promise.all([ + waitForElementInIframe(page, '#participant-name-input', { state: 'visible' }), + waitForElementInIframe(page, '#participant-e2eekey-input', { state: 'visible' }), + waitForElementInIframe(page2, '#participant-name-input', { state: 'visible' }), + waitForElementInIframe(page2, '#participant-e2eekey-input', { state: 'visible' }), + waitForElementInIframe(page3, '#participant-name-input', { state: 'visible' }), + waitForElementInIframe(page3, '#participant-e2eekey-input', { state: 'visible' }) + ]); + + // Fill participant names + await Promise.all([ + interactWithElementInIframe(page, '#participant-name-input', { + action: 'fill', + value: participant1Name + }), + interactWithElementInIframe(page2, '#participant-name-input', { + action: 'fill', + value: participant2Name + }), + interactWithElementInIframe(page3, '#participant-name-input', { + action: 'fill', + value: participant3Name + }) + ]); + + // Fill E2EE keys (two correct, one wrong) + await Promise.all([ + interactWithElementInIframe(page, '#participant-e2eekey-input', { action: 'fill', value: correctKey }), + interactWithElementInIframe(page2, '#participant-e2eekey-input', { action: 'fill', value: correctKey }), + interactWithElementInIframe(page3, '#participant-e2eekey-input', { action: 'fill', value: wrongKey }) + ]); + + // Join all participants + await Promise.all([ + interactWithElementInIframe(page, '#participant-name-submit', { action: 'click' }), + interactWithElementInIframe(page2, '#participant-name-submit', { action: 'click' }), + interactWithElementInIframe(page3, '#participant-name-submit', { action: 'click' }) + ]); + + // Wait for prejoin page in all pages + await Promise.all([ + waitForElementInIframe(page, 'ov-pre-join', { state: 'visible' }), + waitForElementInIframe(page2, 'ov-pre-join', { state: 'visible' }), + waitForElementInIframe(page3, 'ov-pre-join', { state: 'visible' }) + ]); + + // Click join button in all pages + await Promise.all([ + interactWithElementInIframe(page, '#join-button', { action: 'click' }), + interactWithElementInIframe(page2, '#join-button', { action: 'click' }), + interactWithElementInIframe(page3, '#join-button', { action: 'click' }) + ]); + + // Wait for session to be visible in all pages + await Promise.all([ + waitForElementInIframe(page, 'ov-session', { state: 'visible' }), + waitForElementInIframe(page2, 'ov-session', { state: 'visible' }), + waitForElementInIframe(page3, 'ov-session', { state: 'visible' }) + ]); + + // Wait for participants to connect + await page.waitForTimeout(1000); + + // Check that participant 3 sees encryption error posters for others + // ===== CHECK MASKED NAMES IN VIDEO GRID FOR PARTICIPANT 3 ===== + const participantNameElements3 = await waitForElementInIframe( + page3, + '#layout .participant-name-container #participant-name', + { + state: 'attached', + all: true + } + ); + const participantNames3 = await Promise.all( + participantNameElements3.map((el) => el.evaluate((e) => e.textContent)) + ); + + console.log('Participant Names Seen by Participant 3:', participantNames3); + console.log('Expected: 3 names (own + 2 masked), got:', participantNames3.length); + + // Should have exactly 3 participants + expect(participantNames3.length).toBe(3); + + // Should NOT all be masked (own name should be visible) + expect(participantNames3.every((name) => name?.includes('******'))).toBeFalsy(); + + // Should have exactly 2 masked names + const maskedNames = participantNames3.filter((name) => name?.includes('******')); + expect(maskedNames.length).toBe(2); + + // Should see own name + expect(participantNames3).toContain(participant3Name); + + // Should NOT see the actual names of P1 and P2 + expect(participantNames3.join(' ')).not.toContain(participant1Name); + expect(participantNames3.join(' ')).not.toContain(participant2Name); + + // ===== CHECK MASKED NAMES IN PARTICIPANTS PANEL ===== + await interactWithElementInIframe(page3, '#participants-panel-btn', { action: 'click' }); + await waitForElementInIframe(page3, 'ov-participants-panel', { state: 'visible' }); + + const participantsPanelNames3 = await waitForElementInIframe(page3, '.participant-name-text', { + state: 'visible', + all: true + }); + const panelNamesText3 = await Promise.all( + participantsPanelNames3.map((el) => el.evaluate((e) => e.textContent)) + ); + + console.log('Panel Names Seen by Participant 3:', panelNamesText3); + console.log('Expected: 3 names (own + 2 masked), got:', panelNamesText3.length); + + // Should have exactly 3 participants in panel + expect(panelNamesText3.length).toBe(3); + + // Should NOT all be masked (own name should be visible) + expect(panelNamesText3.every((name) => name?.includes('******'))).toBeFalsy(); + + // Should have exactly 2 masked names + const maskedPanelNames = panelNamesText3.filter((name) => name?.includes('******')); + expect(maskedPanelNames.length).toBe(2); + + // Should see own name + expect(panelNamesText3).toContain(participant3Name); + + // Should NOT see the actual names of P1 and P2 + expect(panelNamesText3.join(' ')).not.toContain(participant1Name); + expect(panelNamesText3.join(' ')).not.toContain(participant2Name); + + await interactWithElementInIframe(page3, '#participants-panel-btn', { action: 'click' }); + await waitForElementInIframe(page3, 'ov-participants-panel', { state: 'hidden' }); + + // ===== CHECK OWN NAME IN SETTINGS PANEL ===== + await openMoreOptionsMenu(page3); + await interactWithElementInIframe(page3, '#toolbar-settings-btn', { action: 'click' }); + await waitForElementInIframe(page3, 'ov-settings-panel', { state: 'visible' }); + + const ownNameInput3 = await waitForElementInIframe(page3, '#participant-name-input', { state: 'visible' }); + const ownName3 = await ownNameInput3.evaluate((el: HTMLInputElement) => el.value); + expect(ownName3).toBe(participant3Name); + expect(ownName3).not.toContain('******'); + + await interactWithElementInIframe(page3, '.panel-close-button', { action: 'click' }); + await waitForElementInIframe(page3, 'ov-settings-panel', { state: 'hidden' }); + await closeMoreOptionsMenu(page3); + + // ===== SEND MESSAGE FROM PARTICIPANT 1 ===== + const secretMessage = `Secret message from ${participant1Name}`; + await Promise.all([ + interactWithElementInIframe(page, '#chat-panel-btn', { action: 'click' }), + waitForElementInIframe(page, 'ov-chat-panel', { state: 'visible' }) + ]); + + // Send message + await interactWithElementInIframe(page, '#chat-input', { action: 'fill', value: secretMessage }); + await interactWithElementInIframe(page, '#send-btn', { action: 'click' }); + + // Wait for message to be sent and received + await Promise.all([ + waitForElementInIframe(page2, '#chat-panel-btn .mat-badge-content', { state: 'visible' }), + waitForElementInIframe(page3, '#chat-panel-btn .mat-badge-content', { state: 'visible' }) + ]); + + // ===== CHECK CHAT MESSAGES ARE UNREADABLE ===== + await interactWithElementInIframe(page3, '#chat-panel-btn', { action: 'click' }); + await waitForElementInIframe(page3, 'ov-chat-panel', { state: 'visible' }); + + await page3.waitForTimeout(1000); + + const chatMessagesCount = await countElementsInIframe(page3, '.chat-message'); + expect(chatMessagesCount).toBeGreaterThan(0); + + const chatMessages3 = await waitForElementInIframe(page3, '.chat-message', { + state: 'visible', + all: true + }); + const messagesText3 = await Promise.all(chatMessages3.map((el) => el.evaluate((e) => e.textContent))); + + console.log('Chat Messages Seen by Participant 3:', messagesText3); + console.log('Expected: All messages masked, got:', messagesText3.length, 'messages'); + + // All messages should contain the mask + expect(messagesText3.every((text) => text?.includes('******'))).toBeTruthy(); + + // Should NOT contain the actual secret message + expect(messagesText3.join(' ')).not.toContain(secretMessage); + + // ===== VERIFY PARTICIPANTS 1 AND 2 CAN STILL SEE EACH OTHER ===== + const participantNameElements1 = await waitForElementInIframe(page, '.participant-name', { + state: 'visible', + all: true + }); + const participantNames1 = await Promise.all( + participantNameElements1.map((el) => el.evaluate((e) => e.textContent)) + ); + expect(participantNames1.join(' ')).toContain(participant2Name); + + const participantNameElements2 = await waitForElementInIframe(page2, '.participant-name', { + state: 'visible', + all: true + }); + const participantNames2 = await Promise.all( + participantNameElements2.map((el) => el.evaluate((e) => e.textContent)) + ); + expect(participantNames2.join(' ')).toContain(participant1Name); + + // Cleanup + await Promise.all([leaveRoom(page2), leaveRoom(page3)]); + await Promise.all([page2.close(), page3.close()]); + }); }); // ========================================== diff --git a/meet-ce/frontend/webcomponent/tests/helpers/function-helpers.ts b/meet-ce/frontend/webcomponent/tests/helpers/function-helpers.ts index a60448ad..512b76a8 100644 --- a/meet-ce/frontend/webcomponent/tests/helpers/function-helpers.ts +++ b/meet-ce/frontend/webcomponent/tests/helpers/function-helpers.ts @@ -24,12 +24,43 @@ export async function getIframeInShadowDom( } /** - * Waits for an element inside an iframe within Shadow DOM - * @param page - Playwright page object - * @param elementSelector - Selector for the element inside the iframe - * @param options - Optional configuration - * @returns Locator for the found element + * Waits for one or more elements inside an iframe within a Shadow DOM. + * + * By default, waits for the first matching element. + * If `options.all` is set to `true`, waits for all matching elements and returns an array. + * + * @param page - Playwright `Page` instance. + * @param elementSelector - CSS selector for the target element(s) inside the iframe. + * @param options - Optional configuration object. + * @param options.componentSelector - Selector for the shadow DOM component that contains the iframe. Defaults to `'openvidu-meet'`. + * @param options.iframeSelector - Selector for the iframe inside the shadow DOM. Defaults to `'iframe'`. + * @param options.timeout - Maximum time in milliseconds to wait. Defaults to `30000`. + * @param options.state - Wait condition: `'attached' | 'detached' | 'visible' | 'hidden'`. Defaults to `'visible'`. + * @param options.index - Element index to return when multiple elements match. Defaults to `0`. + * @param options.all - If `true`, waits for all matching elements and returns an array of locators. Defaults to `false`. + * + * @returns A single `Locator` by default, or an array of `Locator[]` when `options.all` is `true`. + * + * @example + * // Wait for the first visible element + * const element = await waitForElementInIframe(page, '.participant'); + * + * @example + * // Wait for all visible elements + * const elements = await waitForElementInIframe(page, '.participant', { all: true }); */ +export async function waitForElementInIframe( + page: Page, + elementSelector: string, + options?: { + componentSelector?: string; + iframeSelector?: string; + timeout?: number; + state?: 'attached' | 'detached' | 'visible' | 'hidden'; + index?: number; + all?: false; + } +): Promise; export async function waitForElementInIframe( page: Page, elementSelector: string, @@ -38,24 +69,42 @@ export async function waitForElementInIframe( iframeSelector?: string; timeout?: number; state?: 'attached' | 'detached' | 'visible' | 'hidden'; + all: true; + } +): Promise; +export async function waitForElementInIframe( + page: Page, + elementSelector: string, + options: { + componentSelector?: string; + iframeSelector?: string; + timeout?: number; + state?: 'attached' | 'detached' | 'visible' | 'hidden'; + index?: number; + all?: boolean; } = {} -): Promise { +): Promise { const { componentSelector = 'openvidu-meet', iframeSelector = 'iframe', timeout = 30000, - state = 'visible' + state = 'visible', + index = 0, + all = false } = options; - // Get the iframe const frameLocator = await getIframeInShadowDom(page, componentSelector, iframeSelector); + const baseLocator = frameLocator.locator(elementSelector); - // Get element locator - const elementLocator = frameLocator.locator(elementSelector); + if (all) { + const locators = await baseLocator.all(); + await Promise.all(locators.map((l) => l.waitFor({ state, timeout }))); + return locators; + } - // Wait for the element with the specified state - await elementLocator.waitFor({ state, timeout }); - return elementLocator; + const target = baseLocator.nth(index); + await target.waitFor({ state, timeout }); + return target; } export async function countElementsInIframe( diff --git a/meet-ce/frontend/webcomponent/tests/unit/attributes.test.ts b/meet-ce/frontend/webcomponent/tests/unit/attributes.test.ts index 48799d61..ef07cb11 100644 --- a/meet-ce/frontend/webcomponent/tests/unit/attributes.test.ts +++ b/meet-ce/frontend/webcomponent/tests/unit/attributes.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; import { OpenViduMeet } from '../../src/components/OpenViduMeet'; +import { WebComponentProperty } from '@openvidu-meet/typings'; import '../../src/index'; describe('OpenViduMeet WebComponent Attributes', () => { @@ -15,55 +16,391 @@ describe('OpenViduMeet WebComponent Attributes', () => { document.body.innerHTML = ''; }); - it('should render iframe with correct attributes', () => { - const iframe = component.shadowRoot?.querySelector('iframe'); - expect(iframe).not.toBeNull(); - expect(iframe?.getAttribute('allow')).toContain('camera'); - expect(iframe?.getAttribute('allow')).toContain('microphone'); - expect(iframe?.getAttribute('allow')).toContain('display-capture'); - expect(iframe?.getAttribute('allow')).toContain('fullscreen'); - expect(iframe?.getAttribute('allow')).toContain('autoplay'); - expect(iframe?.getAttribute('allow')).toContain('compute-pressure'); + // ========================================== + // IFRAME SETUP + // ========================================== + describe('Iframe Configuration', () => { + it('should render iframe with correct media permissions', () => { + const iframe = component.shadowRoot?.querySelector('iframe'); + expect(iframe).not.toBeNull(); + + const allowAttribute = iframe?.getAttribute('allow'); + expect(allowAttribute).toContain('camera'); + expect(allowAttribute).toContain('microphone'); + expect(allowAttribute).toContain('display-capture'); + expect(allowAttribute).toContain('fullscreen'); + expect(allowAttribute).toContain('autoplay'); + expect(allowAttribute).toContain('compute-pressure'); + }); + + it('should have iframe ready in shadow DOM', () => { + const iframe = component.shadowRoot?.querySelector('iframe'); + expect(iframe).toBeInstanceOf(HTMLIFrameElement); + }); }); - it('should reject rendering iframe when "room-url" attribute is missing', () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + // ========================================== + // REQUIRED ATTRIBUTES (room-url | recording-url) + // ========================================== + describe('Required Attributes', () => { + it('should reject iframe src when both room-url and recording-url are missing', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - // Trigger updateIframeSrc manually - (component as any).updateIframeSrc(); + // Trigger updateIframeSrc manually + (component as any).updateIframeSrc(); - const iframe = component.shadowRoot?.querySelector('iframe'); + const iframe = component.shadowRoot?.querySelector('iframe'); - expect(iframe).toBeDefined(); - expect(iframe?.src).toBeFalsy(); - expect(consoleErrorSpy).toHaveBeenCalledWith('The "room-url" or "recording-url" attribute is required.'); + expect(iframe).toBeDefined(); + expect(iframe?.src).toBeFalsy(); + expect(consoleErrorSpy).toHaveBeenCalledWith('The "room-url" or "recording-url" attribute is required.'); - consoleErrorSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('should set iframe src when room-url attribute is provided', () => { + const roomUrl = 'https://example.com/room/testRoom-123'; + component.setAttribute(WebComponentProperty.ROOM_URL, roomUrl); + + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + expect(iframe?.src).toBe(roomUrl); + }); + + it('should set iframe src when recording-url attribute is provided', () => { + const recordingUrl = 'https://example.com/recordings/recording-abc-123'; + component.setAttribute(WebComponentProperty.RECORDING_URL, recordingUrl); + + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + expect(iframe?.src).toBe(recordingUrl); + }); + + it('should prefer room-url over recording-url when both are provided', () => { + const roomUrl = 'https://example.com/room/testRoom-123'; + const recordingUrl = 'https://example.com/recordings/recording-abc-123'; + + component.setAttribute(WebComponentProperty.ROOM_URL, roomUrl); + component.setAttribute(WebComponentProperty.RECORDING_URL, recordingUrl); + + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + expect(iframe?.src).toBe(roomUrl); + }); + + it('should extract origin from room-url and set as target origin', () => { + const domain = 'https://example.com'; + const roomUrl = `${domain}/room/testRoom-123?secret=123456`; + component.setAttribute(WebComponentProperty.ROOM_URL, roomUrl); + + (component as any).updateIframeSrc(); + + expect((component as any).targetIframeOrigin).toBe(domain); + expect((component as any).commandsManager.targetIframeOrigin).toBe(domain); + expect((component as any).eventsManager.targetIframeOrigin).toBe(domain); + }); + + it('should extract origin from recording-url and set as target origin', () => { + const domain = 'https://recordings.example.com'; + const recordingUrl = `${domain}/recordings/recording-abc-123`; + component.setAttribute(WebComponentProperty.RECORDING_URL, recordingUrl); + + (component as any).updateIframeSrc(); + + expect((component as any).targetIframeOrigin).toBe(domain); + expect((component as any).commandsManager.targetIframeOrigin).toBe(domain); + expect((component as any).eventsManager.targetIframeOrigin).toBe(domain); + }); + + it('should update iframe src when room-url attribute changes', () => { + const roomUrl1 = 'https://example.com/room/room-1'; + const roomUrl2 = 'https://example.com/room/room-2'; + + component.setAttribute(WebComponentProperty.ROOM_URL, roomUrl1); + (component as any).updateIframeSrc(); + + let iframe = component.shadowRoot?.querySelector('iframe'); + expect(iframe?.src).toBe(roomUrl1); + + component.setAttribute(WebComponentProperty.ROOM_URL, roomUrl2); + (component as any).updateIframeSrc(); + + iframe = component.shadowRoot?.querySelector('iframe'); + expect(iframe?.src).toBe(roomUrl2); + }); }); - it('should update iframe src when "room-url" attribute changes', () => { - const roomUrl = 'https://example.com/room/testRoom-123?secret=123456'; - component.setAttribute('room-url', roomUrl); - component.setAttribute('user', 'testUser'); + // ========================================== + // OPTIONAL ATTRIBUTES AS QUERY PARAMETERS + // ========================================== + describe('Optional Attributes as Query Parameters', () => { + const baseRoomUrl = 'https://example.com/room/testRoom'; - // Manually trigger the update (MutationObserver doesn't always trigger in tests) - (component as any).updateIframeSrc(); + it('should add participant-name as query parameter', () => { + const participantName = 'John Doe'; + component.setAttribute(WebComponentProperty.ROOM_URL, baseRoomUrl); + component.setAttribute(WebComponentProperty.PARTICIPANT_NAME, participantName); - const iframe = component.shadowRoot?.querySelector('iframe'); - expect(iframe?.src).toEqual(`${roomUrl}&user=testUser`); + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + const url = new URL(iframe?.src || ''); + expect(url.searchParams.get(WebComponentProperty.PARTICIPANT_NAME)).toBe(participantName); + }); + + it('should add e2ee-key as query parameter', () => { + const e2eeKey = 'secret-encryption-key-123'; + component.setAttribute(WebComponentProperty.ROOM_URL, baseRoomUrl); + component.setAttribute(WebComponentProperty.E2EE_KEY, e2eeKey); + + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + const url = new URL(iframe?.src || ''); + expect(url.searchParams.get(WebComponentProperty.E2EE_KEY)).toBe(e2eeKey); + }); + + it('should add leave-redirect-url as query parameter', () => { + const redirectUrl = 'https://example.com/goodbye'; + component.setAttribute(WebComponentProperty.ROOM_URL, baseRoomUrl); + component.setAttribute(WebComponentProperty.LEAVE_REDIRECT_URL, redirectUrl); + + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + const url = new URL(iframe?.src || ''); + expect(url.searchParams.get(WebComponentProperty.LEAVE_REDIRECT_URL)).toBe(redirectUrl); + }); + + it('should add show-only-recordings as query parameter', () => { + component.setAttribute(WebComponentProperty.ROOM_URL, baseRoomUrl); + component.setAttribute(WebComponentProperty.SHOW_ONLY_RECORDINGS, 'true'); + + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + const url = new URL(iframe?.src || ''); + expect(url.searchParams.get(WebComponentProperty.SHOW_ONLY_RECORDINGS)).toBe('true'); + }); + + it('should add multiple optional attributes as query parameters', () => { + const participantName = 'Jane Smith'; + const e2eeKey = 'encryption-key-456'; + const redirectUrl = 'https://example.com/thanks'; + + component.setAttribute(WebComponentProperty.ROOM_URL, baseRoomUrl); + component.setAttribute(WebComponentProperty.PARTICIPANT_NAME, participantName); + component.setAttribute(WebComponentProperty.E2EE_KEY, e2eeKey); + component.setAttribute(WebComponentProperty.LEAVE_REDIRECT_URL, redirectUrl); + component.setAttribute(WebComponentProperty.SHOW_ONLY_RECORDINGS, 'false'); + + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + const url = new URL(iframe?.src || ''); + + expect(url.searchParams.get(WebComponentProperty.PARTICIPANT_NAME)).toBe(participantName); + expect(url.searchParams.get(WebComponentProperty.E2EE_KEY)).toBe(e2eeKey); + expect(url.searchParams.get(WebComponentProperty.LEAVE_REDIRECT_URL)).toBe(redirectUrl); + expect(url.searchParams.get(WebComponentProperty.SHOW_ONLY_RECORDINGS)).toBe('false'); + }); + + it('should NOT add room-url or recording-url as query parameters', () => { + const roomUrl = 'https://example.com/room/testRoom?secret=abc'; + component.setAttribute(WebComponentProperty.ROOM_URL, roomUrl); + + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + const url = new URL(iframe?.src || ''); + + // room-url should not be in query params (it's the base URL) + expect(url.searchParams.has(WebComponentProperty.ROOM_URL)).toBe(false); + expect(url.searchParams.has(WebComponentProperty.RECORDING_URL)).toBe(false); + }); + + it('should preserve existing query parameters in room-url', () => { + const roomUrl = 'https://example.com/room/testRoom?secret=abc123&role=moderator'; + const participantName = 'Alice'; + + component.setAttribute(WebComponentProperty.ROOM_URL, roomUrl); + component.setAttribute(WebComponentProperty.PARTICIPANT_NAME, participantName); + + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + const url = new URL(iframe?.src || ''); + + // Original query params should be preserved + expect(url.searchParams.get('secret')).toBe('abc123'); + expect(url.searchParams.get('role')).toBe('moderator'); + // New param should be added + expect(url.searchParams.get(WebComponentProperty.PARTICIPANT_NAME)).toBe(participantName); + }); }); - it('should extract origin from room-url and set as allowed origin', () => { - const domain = 'https://example.com'; - const roomUrl = `${domain}/room/testRoom-123?secret=123456`; - component.setAttribute('room-url', roomUrl); + // ========================================== + // CUSTOM/UNKNOWN ATTRIBUTES + // ========================================== + describe('Custom Attributes as Query Parameters', () => { + it('should add custom attributes as query parameters', () => { + const baseRoomUrl = 'https://example.com/room/testRoom'; + component.setAttribute(WebComponentProperty.ROOM_URL, baseRoomUrl); + component.setAttribute('custom-attr', 'custom-value'); + component.setAttribute('another-param', 'another-value'); - // Trigger update - (component as any).updateIframeSrc(); + (component as any).updateIframeSrc(); - // Check if origin was extracted and set - expect((component as any).targetIframeOrigin).toBe(domain); - expect((component as any).commandsManager.targetIframeOrigin).toBe(domain); - expect((component as any).eventsManager.targetIframeOrigin).toBe(domain); + const iframe = component.shadowRoot?.querySelector('iframe'); + const url = new URL(iframe?.src || ''); + + expect(url.searchParams.get('custom-attr')).toBe('custom-value'); + expect(url.searchParams.get('another-param')).toBe('another-value'); + }); + + it('should handle attribute names with special characters', () => { + const baseRoomUrl = 'https://example.com/room/testRoom'; + component.setAttribute(WebComponentProperty.ROOM_URL, baseRoomUrl); + component.setAttribute('data-test-id', '12345'); + + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + const url = new URL(iframe?.src || ''); + + expect(url.searchParams.get('data-test-id')).toBe('12345'); + }); + }); + + // ========================================== + // EDGE CASES + // ========================================== + describe('Edge Cases', () => { + it('should handle empty string attributes', () => { + const baseRoomUrl = 'https://example.com/room/testRoom'; + component.setAttribute(WebComponentProperty.ROOM_URL, baseRoomUrl); + component.setAttribute(WebComponentProperty.PARTICIPANT_NAME, ''); + + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + const url = new URL(iframe?.src || ''); + + // Empty string should still be added as query param + expect(url.searchParams.has(WebComponentProperty.PARTICIPANT_NAME)).toBe(true); + expect(url.searchParams.get(WebComponentProperty.PARTICIPANT_NAME)).toBe(''); + }); + + it('should handle special characters in attribute values', () => { + const baseRoomUrl = 'https://example.com/room/testRoom'; + const specialName = 'User Name With Spaces & Special=Chars'; + component.setAttribute(WebComponentProperty.ROOM_URL, baseRoomUrl); + component.setAttribute(WebComponentProperty.PARTICIPANT_NAME, specialName); + + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + const url = new URL(iframe?.src || ''); + + // Should be URL-encoded properly + expect(url.searchParams.get(WebComponentProperty.PARTICIPANT_NAME)).toBe(specialName); + }); + + it('should handle updating attributes after initial render', () => { + const baseRoomUrl = 'https://example.com/room/testRoom'; + component.setAttribute(WebComponentProperty.ROOM_URL, baseRoomUrl); + (component as any).updateIframeSrc(); + + const initialSrc = component.shadowRoot?.querySelector('iframe')?.src; + + // Update an attribute + component.setAttribute(WebComponentProperty.PARTICIPANT_NAME, 'Updated Name'); + (component as any).updateIframeSrc(); + + const updatedSrc = component.shadowRoot?.querySelector('iframe')?.src; + + expect(initialSrc).not.toBe(updatedSrc); + + const url = new URL(updatedSrc || ''); + expect(url.searchParams.get(WebComponentProperty.PARTICIPANT_NAME)).toBe('Updated Name'); + }); + + it('should handle invalid URL gracefully', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + // Set an invalid URL + component.setAttribute(WebComponentProperty.ROOM_URL, 'not-a-valid-url'); + + // Call updateIframeSrc directly - it should catch the error and log it + (component as any).updateIframeSrc(); + + // Verify error was logged with the invalid URL + expect(consoleErrorSpy).toHaveBeenCalledWith('Invalid URL provided: not-a-valid-url', expect.anything()); + + consoleErrorSpy.mockRestore(); + }); + }); + + // ========================================== + // INTEGRATION TESTS + // ========================================== + describe('Integration Tests', () => { + it('should handle complete real-world scenario with room-url and multiple attributes', () => { + const roomUrl = 'https://meet.example.com/room/team-standup?secret=xyz789'; + const participantName = 'John Doe'; + const e2eeKey = 'my-secure-key'; + const redirectUrl = 'https://example.com/dashboard'; + + component.setAttribute(WebComponentProperty.ROOM_URL, roomUrl); + component.setAttribute(WebComponentProperty.PARTICIPANT_NAME, participantName); + component.setAttribute(WebComponentProperty.E2EE_KEY, e2eeKey); + component.setAttribute(WebComponentProperty.LEAVE_REDIRECT_URL, redirectUrl); + + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + const url = new URL(iframe?.src || ''); + + // Verify base URL + expect(url.origin).toBe('https://meet.example.com'); + expect(url.pathname).toBe('/room/team-standup'); + + // Verify all query parameters + expect(url.searchParams.get('secret')).toBe('xyz789'); + expect(url.searchParams.get(WebComponentProperty.PARTICIPANT_NAME)).toBe(participantName); + expect(url.searchParams.get(WebComponentProperty.E2EE_KEY)).toBe(e2eeKey); + expect(url.searchParams.get(WebComponentProperty.LEAVE_REDIRECT_URL)).toBe(redirectUrl); + + // Verify origin was set correctly + expect((component as any).targetIframeOrigin).toBe('https://meet.example.com'); + }); + + it('should handle complete real-world scenario with recording-url', () => { + const recordingUrl = 'https://recordings.example.com/view/rec-20231115-abc123'; + const participantName = 'Viewer'; + + component.setAttribute(WebComponentProperty.RECORDING_URL, recordingUrl); + component.setAttribute(WebComponentProperty.PARTICIPANT_NAME, participantName); + component.setAttribute(WebComponentProperty.SHOW_ONLY_RECORDINGS, 'true'); + + (component as any).updateIframeSrc(); + + const iframe = component.shadowRoot?.querySelector('iframe'); + const url = new URL(iframe?.src || ''); + + // Verify base URL + expect(url.origin).toBe('https://recordings.example.com'); + expect(url.pathname).toBe('/view/rec-20231115-abc123'); + + // Verify query parameters + expect(url.searchParams.get(WebComponentProperty.PARTICIPANT_NAME)).toBe(participantName); + expect(url.searchParams.get(WebComponentProperty.SHOW_ONLY_RECORDINGS)).toBe('true'); + + // Verify origin was set correctly + expect((component as any).targetIframeOrigin).toBe('https://recordings.example.com'); + }); }); }); diff --git a/meet-ce/typings/src/webcomponent/properties.model.ts b/meet-ce/typings/src/webcomponent/properties.model.ts index 7b39c898..f573ff3b 100644 --- a/meet-ce/typings/src/webcomponent/properties.model.ts +++ b/meet-ce/typings/src/webcomponent/properties.model.ts @@ -14,6 +14,12 @@ export enum WebComponentProperty { */ PARTICIPANT_NAME = 'participant-name', + /** + * Secret key for end-to-end encryption (E2EE). + * If provided, the participant will join the meeting using E2EE key. + */ + E2EE_KEY = 'e2ee-key', + /** * URL to redirect to when leaving the meeting. * Redirection occurs after the **`CLOSED` event** fires. diff --git a/meet.sh b/meet.sh index b339a76a..7e9c6972 100755 --- a/meet.sh +++ b/meet.sh @@ -622,30 +622,42 @@ build_webcomponent_doc() { mkdir -p "$output_dir" fi - if [ -f "docs/webcomponent-events.md" ] && [ -f "docs/webcomponent-commands.md" ] && [ -f "docs/webcomponent-attributes.md" ]; then + if [ -f "docs/webcomponent-events.md" ] && \ + [ -f "docs/webcomponent-commands.md" ] && \ + [ -f "docs/webcomponent-attributes.md" ]; then echo -e "${GREEN}Copying documentation to: $output_dir${NC}" - cp docs/webcomponent-events.md "$output_dir/webcomponent-events.md" - cp docs/webcomponent-commands.md "$output_dir/webcomponent-commands.md" - cp docs/webcomponent-attributes.md "$output_dir/webcomponent-attributes.md" + cp docs/webcomponent-{events,commands,attributes}.md "$output_dir"/ echo -e "${GREEN}✓ Documentation copied successfully!${NC}" + rm -f docs/webcomponent-{events,commands,attributes}.md else echo -e "${RED}Error: Documentation files not found in docs/ directory${NC}" exit 1 fi else echo -e "${YELLOW}No output directory specified. Documentation remains in docs/ directory.${NC}" + output_dir="docs" + fi + + local abs_path + if command -v realpath >/dev/null 2>&1; then + abs_path=$(realpath "$output_dir") + elif command -v readlink >/dev/null 2>&1; then + abs_path=$(readlink -f "$output_dir" 2>/dev/null || (cd "$output_dir" && pwd)) + else + abs_path=$(cd "$output_dir" && pwd) fi echo echo -e "${GREEN}✓ Webcomponent documentation generated successfully!${NC}" - echo -e "${YELLOW}Output directory: $output_dir${NC}" - rm -f docs/webcomponent-events.md docs/webcomponent-commands.md docs/webcomponent-attributes.md + echo -e "${YELLOW}Output directory: ${abs_path}${NC}" } + # Build REST API documentation build_rest_api_doc() { - local output_dir="$1" + local output_target="$1" CE_REST_API_DOC_PATH="meet-ce/backend/public/openapi/" + echo -e "${BLUE}=====================================${NC}" echo -e "${BLUE} Building REST API Docs${NC}" echo -e "${BLUE}=====================================${NC}" @@ -653,31 +665,56 @@ build_rest_api_doc() { check_pnpm + # Solo instalar si no existen dependencias locales del backend + if [ ! -d "node_modules" ] || [ ! -d "meet-ce/backend/node_modules" ]; then + echo -e "${YELLOW}Backend dependencies not found. Installing minimal backend deps...${NC}" + pnpm --filter @openvidu-meet/backend install + else + echo -e "${GREEN}Backend dependencies already present. Skipping install.${NC}" + fi + echo -e "${GREEN}Generating REST API documentation...${NC}" pnpm run build:rest-api-docs - if [ -n "$output_dir" ]; then - output_dir="${output_dir%/}" + # Determinar si el parámetro es archivo o directorio + local output_dir output_file + if [[ "$output_target" =~ \.html$ ]]; then + output_dir=$(dirname "$output_target") + output_file="$output_target" + else + output_dir="${output_target%/}" + output_file="$output_dir/public.html" + fi - if [ ! -d "$output_dir" ]; then - echo -e "${YELLOW}Creating output directory: $output_dir${NC}" - mkdir -p "$output_dir" - fi + # Crear carpeta contenedora si no existe + if [ ! -d "$output_dir" ]; then + echo -e "${YELLOW}Creating output directory: $output_dir${NC}" + mkdir -p "$output_dir" + fi - if [ -f "$CE_REST_API_DOC_PATH/public.html" ]; then - echo -e "${GREEN}Copying REST API documentation to: $output_dir${NC}" - cp "$CE_REST_API_DOC_PATH/public.html" "$output_dir/public.html" - echo -e "${GREEN}✓ Documentation copied successfully!${NC}" - else - echo -e "${RED}Error: REST API documentation files not found${NC}" + # Preguntar si el archivo ya existe + if [ -f "$output_file" ]; then + echo -e "${YELLOW}Warning: '$output_file' already exists.${NC}" + read -rp "Do you want to overwrite it? [y/N]: " confirm + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo -e "${RED}Operation cancelled by user.${NC}" exit 1 fi + fi + + # Copiar documentación + if [ -f "$CE_REST_API_DOC_PATH/public.html" ]; then + echo -e "${GREEN}Copying REST API documentation to: $output_file${NC}" + cp "$CE_REST_API_DOC_PATH/public.html" "$output_file" + echo -e "${GREEN}✓ Documentation copied successfully!${NC}" else - echo -e "${YELLOW}No output directory specified. Documentation remains in backend/ directory.${NC}" + echo -e "${RED}Error: REST API documentation files not found${NC}" + exit 1 fi echo echo -e "${GREEN}✓ REST API documentation generated successfully!${NC}" + echo -e "${YELLOW}Output file: $(cd "$(dirname "$output_file")" && pwd)/$(basename "$output_file")${NC}" } # Clone private meet-pro repository into repository root diff --git a/openvidu-meet.code-workspace b/openvidu-meet.code-workspace index ef85e143..d21368f2 100644 --- a/openvidu-meet.code-workspace +++ b/openvidu-meet.code-workspace @@ -2,42 +2,42 @@ "folders": [ { "name": "openvidu-components-angular", - "path": "../openvidu/openvidu-components-angular", + "path": "../openvidu/openvidu-components-angular" }, { "name": "openvidu-meet (root)", - "path": ".", + "path": "." }, { "name": "openvidu-meet (CE)", - "path": "meet-ce", + "path": "meet-ce" }, { "name": "openvidu-meet (PRO)", - "path": "meet-pro", + "path": "meet-pro" }, { "name": "shared-meet-components", - "path": "meet-ce/frontend/projects/shared-meet-components", + "path": "meet-ce/frontend/projects/shared-meet-components" }, { "name": "meet-testapp", - "path": "testapp", + "path": "testapp" }, { "name": "meet-webcomponent", - "path": "meet-ce/frontend/webcomponent", - }, + "path": "meet-ce/frontend/webcomponent" + } ], "settings": { "files.exclude": { "**/meet-ce": true, "**/meet-pro": true, - "**/webcomponent": true, + "**/frontend/webcomponent": true, "**/webhooks-snippets": false, "**/testapp": true, "**/.angular": true, - "**/public": true, + "**/public": false, "**/dist": false, "**/node_modules": true, "**/test-results": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc826602..05914562 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -501,6 +501,365 @@ importers: specifier: 5.9.2 version: 5.9.2 + meet-pro/backend: + dependencies: + '@aws-sdk/client-s3': + specifier: 3.846.0 + version: 3.846.0 + '@azure/storage-blob': + specifier: 12.27.0 + version: 12.27.0 + '@google-cloud/storage': + specifier: 7.17.1 + version: 7.17.1(encoding@0.1.13) + '@openvidu-meet-pro/typings': + specifier: workspace:* + version: link:../typings + '@openvidu-meet/backend': + specifier: workspace:* + version: link:../../meet-ce/backend + '@sesamecare-oss/redlock': + specifier: 1.4.0 + version: 1.4.0(ioredis@5.6.1) + archiver: + specifier: 7.0.1 + version: 7.0.1 + bcrypt: + specifier: 5.1.1 + version: 5.1.1(encoding@0.1.13) + body-parser: + specifier: 2.2.0 + version: 2.2.0 + chalk: + specifier: 5.6.2 + version: 5.6.2 + cookie-parser: + specifier: 1.4.7 + version: 1.4.7 + cors: + specifier: 2.8.5 + version: 2.8.5 + cron: + specifier: 4.3.3 + version: 4.3.3 + dotenv: + specifier: 16.6.1 + version: 16.6.1 + express: + specifier: 4.21.2 + version: 4.21.2 + express-rate-limit: + specifier: 7.5.1 + version: 7.5.1(express@4.21.2) + inversify: + specifier: 6.2.2 + version: 6.2.2(reflect-metadata@0.2.2) + ioredis: + specifier: 5.6.1 + version: 5.6.1 + jwt-decode: + specifier: 4.0.0 + version: 4.0.0 + livekit-server-sdk: + specifier: 2.13.1 + version: 2.13.1 + ms: + specifier: 2.1.3 + version: 2.1.3 + uid: + specifier: 2.0.2 + version: 2.0.2 + winston: + specifier: 3.18.3 + version: 3.18.3 + yamljs: + specifier: 0.3.0 + version: 0.3.0 + zod: + specifier: 3.25.76 + version: 3.25.76 + devDependencies: + '@types/archiver': + specifier: 6.0.3 + version: 6.0.3 + '@types/bcrypt': + specifier: 5.0.2 + version: 5.0.2 + '@types/cookie-parser': + specifier: 1.4.9 + version: 1.4.9(@types/express@4.17.23) + '@types/cors': + specifier: 2.8.19 + version: 2.8.19 + '@types/express': + specifier: 4.17.23 + version: 4.17.23 + '@types/jest': + specifier: 29.5.14 + version: 29.5.14 + '@types/ms': + specifier: 2.1.0 + version: 2.1.0 + '@types/node': + specifier: 22.16.4 + version: 22.16.4 + '@types/supertest': + specifier: 6.0.3 + version: 6.0.3 + '@types/unzipper': + specifier: 0.10.11 + version: 0.10.11 + '@types/validator': + specifier: 13.15.2 + version: 13.15.2 + '@types/yamljs': + specifier: 0.2.34 + version: 0.2.34 + '@typescript-eslint/eslint-plugin': + specifier: 6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: 6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.2) + cross-env: + specifier: 7.0.3 + version: 7.0.3 + eslint: + specifier: 8.57.1 + version: 8.57.1 + eslint-config-prettier: + specifier: 9.1.0 + version: 9.1.0(eslint@8.57.1) + jest: + specifier: 29.7.0 + version: 29.7.0(@types/node@22.16.4)(ts-node@10.9.2(@types/node@22.16.4)(typescript@5.9.2)) + jest-fetch-mock: + specifier: 3.0.3 + version: 3.0.3(encoding@0.1.13) + jest-junit: + specifier: 16.0.0 + version: 16.0.0 + nodemon: + specifier: 3.1.10 + version: 3.1.10 + openapi-generate-html: + specifier: 0.5.3 + version: 0.5.3(@types/node@22.16.4) + prettier: + specifier: 3.6.2 + version: 3.6.2 + supertest: + specifier: 7.1.3 + version: 7.1.3 + ts-jest: + specifier: 29.4.0 + version: 29.4.0(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.16.4)(ts-node@10.9.2(@types/node@22.16.4)(typescript@5.9.2)))(typescript@5.9.2) + ts-jest-resolver: + specifier: 2.0.1 + version: 2.0.1 + tsx: + specifier: 4.20.3 + version: 4.20.3 + typescript: + specifier: 5.9.2 + version: 5.9.2 + unzipper: + specifier: 0.12.3 + version: 0.12.3 + + meet-pro/frontend: + dependencies: + '@angular/animations': + specifier: 20.3.4 + version: 20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)) + '@angular/cdk': + specifier: 20.2.9 + version: 20.2.9(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + '@angular/common': + specifier: 20.3.4 + version: 20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + '@angular/compiler': + specifier: 20.3.4 + version: 20.3.4 + '@angular/core': + specifier: 20.3.4 + version: 20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/forms': + specifier: 20.3.4 + version: 20.3.4(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) + '@angular/material': + specifier: 20.2.9 + version: 20.2.9(b517547b325ffc8400ae4cda6a618bfd) + '@angular/platform-browser': + specifier: 20.3.4 + version: 20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)) + '@angular/platform-browser-dynamic': + specifier: 20.3.4 + version: 20.3.4(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))) + '@angular/router': + specifier: 20.3.4 + version: 20.3.4(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) + '@livekit/track-processors': + specifier: 0.6.1 + version: 0.6.1(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.15.11(@types/dom-mediacapture-record@1.0.22)) + '@openvidu-meet/shared-components': + specifier: workspace:* + version: link:../../meet-ce/frontend/projects/shared-meet-components + '@openvidu-meet/typings': + specifier: workspace:* + version: link:../../meet-ce/typings + autolinker: + specifier: 4.1.5 + version: 4.1.5 + core-js: + specifier: 3.45.1 + version: 3.45.1 + jwt-decode: + specifier: 4.0.0 + version: 4.0.0 + livekit-client: + specifier: 2.15.11 + version: 2.15.11(@types/dom-mediacapture-record@1.0.22) + openvidu-components-angular: + specifier: workspace:* + version: link:../../../openvidu/openvidu-components-angular/projects/openvidu-components-angular + rxjs: + specifier: 7.8.2 + version: 7.8.2 + tslib: + specifier: 2.8.1 + version: 2.8.1 + unique-names-generator: + specifier: 4.7.1 + version: 4.7.1 + zone.js: + specifier: 0.15.1 + version: 0.15.1 + devDependencies: + '@angular-builders/custom-webpack': + specifier: 20.0.0 + version: 20.0.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(browser-sync@3.0.4)(chokidar@4.0.3)(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.2)))(jiti@1.21.7)(karma@6.4.4)(less@4.4.2)(ng-packagr@20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2))(postcss@8.5.6)(terser@5.44.0)(tslib@2.8.1)(tsx@4.20.3)(typescript@5.9.2) + '@angular-devkit/build-angular': + specifier: 20.3.4 + version: 20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(browser-sync@3.0.4)(chokidar@4.0.3)(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.2)))(jiti@1.21.7)(karma@6.4.4)(ng-packagr@20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2))(tsx@4.20.3)(typescript@5.9.2) + '@angular-eslint/builder': + specifier: 20.3.0 + version: 20.3.0(chokidar@4.0.3)(eslint@8.57.1)(typescript@5.9.2) + '@angular-eslint/eslint-plugin': + specifier: 20.3.0 + version: 20.3.0(@typescript-eslint/utils@8.46.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)(typescript@5.9.2) + '@angular-eslint/eslint-plugin-template': + specifier: 20.3.0 + version: 20.3.0(@angular-eslint/template-parser@20.3.0(eslint@8.57.1)(typescript@5.9.2))(@typescript-eslint/types@8.46.1)(@typescript-eslint/utils@8.46.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)(typescript@5.9.2) + '@angular-eslint/schematics': + specifier: 20.3.0 + version: 20.3.0(@angular-eslint/template-parser@20.3.0(eslint@8.57.1)(typescript@5.9.2))(@typescript-eslint/types@8.46.1)(@typescript-eslint/utils@8.46.1(eslint@8.57.1)(typescript@5.9.2))(chokidar@4.0.3)(eslint@8.57.1)(typescript@5.9.2) + '@angular-eslint/template-parser': + specifier: 20.3.0 + version: 20.3.0(eslint@8.57.1)(typescript@5.9.2) + '@angular/cli': + specifier: 20.3.4 + version: 20.3.4(@types/node@22.18.8)(chokidar@4.0.3) + '@angular/compiler-cli': + specifier: 20.3.4 + version: 20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2) + '@types/chai': + specifier: 4.3.20 + version: 4.3.20 + '@types/fluent-ffmpeg': + specifier: 2.1.27 + version: 2.1.27 + '@types/jasmine': + specifier: 5.1.9 + version: 5.1.9 + '@types/mocha': + specifier: 9.1.1 + version: 9.1.1 + '@types/node': + specifier: 22.18.8 + version: 22.18.8 + '@types/pixelmatch': + specifier: 5.2.6 + version: 5.2.6 + '@types/pngjs': + specifier: 6.0.5 + version: 6.0.5 + '@types/selenium-webdriver': + specifier: 4.35.1 + version: 4.35.1 + '@typescript-eslint/eslint-plugin': + specifier: 8.46.1 + version: 8.46.1(@typescript-eslint/parser@8.46.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: 8.46.1 + version: 8.46.1(eslint@8.57.1)(typescript@5.9.2) + chai: + specifier: 4.5.0 + version: 4.5.0 + chromedriver: + specifier: 141.0.0 + version: 141.0.0 + cross-env: + specifier: 7.0.3 + version: 7.0.3 + eslint: + specifier: 8.57.1 + version: 8.57.1 + eslint-config-prettier: + specifier: 9.1.0 + version: 9.1.0(eslint@8.57.1) + fluent-ffmpeg: + specifier: 2.1.3 + version: 2.1.3 + jasmine-core: + specifier: 5.6.0 + version: 5.6.0 + jasmine-spec-reporter: + specifier: 7.0.0 + version: 7.0.0 + karma: + specifier: 6.4.4 + version: 6.4.4 + karma-chrome-launcher: + specifier: 3.2.0 + version: 3.2.0 + karma-coverage: + specifier: 2.2.1 + version: 2.2.1 + karma-jasmine: + specifier: 5.1.0 + version: 5.1.0(karma@6.4.4) + karma-jasmine-html-reporter: + specifier: 2.1.0 + version: 2.1.0(jasmine-core@5.6.0)(karma-jasmine@5.1.0(karma@6.4.4))(karma@6.4.4) + mocha: + specifier: 10.7.3 + version: 10.7.3 + ng-packagr: + specifier: 20.3.0 + version: 20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2) + prettier: + specifier: 3.3.3 + version: 3.3.3 + selenium-webdriver: + specifier: 4.25.0 + version: 4.25.0 + ts-node: + specifier: 10.9.2 + version: 10.9.2(@types/node@22.18.8)(typescript@5.9.2) + typescript: + specifier: 5.9.2 + version: 5.9.2 + + meet-pro/typings: + devDependencies: + '@openvidu-meet/typings': + specifier: workspace:* + version: link:../../meet-ce/typings + typescript: + specifier: 5.9.2 + version: 5.9.2 + testapp: dependencies: '@openvidu-meet/typings': @@ -6570,6 +6929,7 @@ packages: livekit-client@2.15.11: resolution: {integrity: sha512-9cHdAbSibPGyt7wWM+GAUswIOuklQHF9y561Oruzh0nNFNvRzMsE10oqJvjs0k6s2Jl+j/Z5Ar90bzVwLpu1yg==} + deprecated: Compatibility issue around AbortSignal.any usage, use >=2.15.12 instead peerDependencies: '@types/dom-mediacapture-record': ^1 @@ -9322,6 +9682,59 @@ snapshots: - webpack-cli - yaml + '@angular-builders/custom-webpack@20.0.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(browser-sync@3.0.4)(chokidar@4.0.3)(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.2)))(jiti@1.21.7)(karma@6.4.4)(less@4.4.2)(ng-packagr@20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2))(postcss@8.5.6)(terser@5.44.0)(tslib@2.8.1)(tsx@4.20.3)(typescript@5.9.2)': + dependencies: + '@angular-builders/common': 4.0.0(@types/node@22.18.8)(chokidar@4.0.3)(typescript@5.9.2) + '@angular-devkit/architect': 0.2003.5(chokidar@4.0.3) + '@angular-devkit/build-angular': 20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(browser-sync@3.0.4)(chokidar@4.0.3)(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.2)))(jiti@1.21.7)(karma@6.4.4)(ng-packagr@20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2))(tsx@4.20.3)(typescript@5.9.2) + '@angular-devkit/core': 20.3.5(chokidar@4.0.3) + '@angular/build': 20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(chokidar@4.0.3)(jiti@1.21.7)(karma@6.4.4)(less@4.4.2)(ng-packagr@20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2))(postcss@8.5.6)(terser@5.44.0)(tslib@2.8.1)(tsx@4.20.3)(typescript@5.9.2) + '@angular/compiler-cli': 20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2) + lodash: 4.17.21 + webpack-merge: 6.0.1 + transitivePeerDependencies: + - '@angular/compiler' + - '@angular/core' + - '@angular/localize' + - '@angular/platform-browser' + - '@angular/platform-server' + - '@angular/service-worker' + - '@angular/ssr' + - '@rspack/core' + - '@swc/core' + - '@swc/wasm' + - '@types/node' + - '@web/test-runner' + - browser-sync + - bufferutil + - chokidar + - debug + - html-webpack-plugin + - jest + - jest-environment-jsdom + - jiti + - karma + - less + - lightningcss + - ng-packagr + - node-sass + - postcss + - protractor + - sass-embedded + - stylus + - sugarss + - supports-color + - tailwindcss + - terser + - tslib + - tsx + - typescript + - uglify-js + - utf-8-validate + - vitest + - webpack-cli + - yaml + '@angular-devkit/architect@0.2003.4(chokidar@4.0.3)': dependencies: '@angular-devkit/core': 20.3.4(chokidar@4.0.3) @@ -9340,7 +9753,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2003.4(chokidar@4.0.3) - '@angular-devkit/build-webpack': 0.2003.4(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2) + '@angular-devkit/build-webpack': 0.2003.4(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2(esbuild@0.25.9)))(webpack@5.101.2(esbuild@0.25.9)) '@angular-devkit/core': 20.3.4(chokidar@4.0.3) '@angular/build': 20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(chokidar@4.0.3)(jiti@1.21.7)(karma@6.4.4)(less@4.4.0)(ng-packagr@20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2))(postcss@8.5.6)(terser@5.43.1)(tslib@2.8.1)(tsx@4.20.3)(typescript@5.9.2) '@angular/compiler-cli': 20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2) @@ -9354,13 +9767,13 @@ snapshots: '@babel/preset-env': 7.28.3(@babel/core@7.28.3) '@babel/runtime': 7.28.3 '@discoveryjs/json-ext': 0.6.3 - '@ngtools/webpack': 20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(typescript@5.9.2)(webpack@5.101.2) + '@ngtools/webpack': 20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(typescript@5.9.2)(webpack@5.101.2(esbuild@0.25.9)) ansi-colors: 4.1.3 autoprefixer: 10.4.21(postcss@8.5.6) - babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2) + babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9)) browserslist: 4.26.3 - copy-webpack-plugin: 13.0.1(webpack@5.101.2) - css-loader: 7.1.2(webpack@5.101.2) + copy-webpack-plugin: 13.0.1(webpack@5.101.2(esbuild@0.25.9)) + css-loader: 7.1.2(webpack@5.101.2(esbuild@0.25.9)) esbuild-wasm: 0.25.9 fast-glob: 3.3.3 http-proxy-middleware: 3.0.5 @@ -9368,32 +9781,32 @@ snapshots: jsonc-parser: 3.3.1 karma-source-map-support: 1.4.0 less: 4.4.0 - less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2) - license-webpack-plugin: 4.0.2(webpack@5.101.2) + less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9)) + license-webpack-plugin: 4.0.2(webpack@5.101.2(esbuild@0.25.9)) loader-utils: 3.3.1 - mini-css-extract-plugin: 2.9.4(webpack@5.101.2) + mini-css-extract-plugin: 2.9.4(webpack@5.101.2(esbuild@0.25.9)) open: 10.2.0 ora: 8.2.0 picomatch: 4.0.3 piscina: 5.1.3 postcss: 8.5.6 - postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.9.2)(webpack@5.101.2) + postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.9.2)(webpack@5.101.2(esbuild@0.25.9)) resolve-url-loader: 5.0.0 rxjs: 7.8.2 sass: 1.90.0 - sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2) + sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9)) semver: 7.7.2 - source-map-loader: 5.0.0(webpack@5.101.2) + source-map-loader: 5.0.0(webpack@5.101.2(esbuild@0.25.9)) source-map-support: 0.5.21 terser: 5.43.1 tree-kill: 1.2.2 tslib: 2.8.1 typescript: 5.9.2 webpack: 5.101.2(esbuild@0.25.9) - webpack-dev-middleware: 7.4.2(webpack@5.101.2) - webpack-dev-server: 5.2.2(webpack@5.101.2) + webpack-dev-middleware: 7.4.2(webpack@5.101.2(esbuild@0.25.9)) + webpack-dev-server: 5.2.2(webpack@5.101.2(esbuild@0.25.9)) webpack-merge: 6.0.1 - webpack-subresource-integrity: 5.1.0(webpack@5.101.2) + webpack-subresource-integrity: 5.1.0(webpack@5.101.2(esbuild@0.25.9)) optionalDependencies: '@angular/core': 20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/platform-browser': 20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)) @@ -9426,12 +9839,12 @@ snapshots: - webpack-cli - yaml - '@angular-devkit/build-webpack@0.2003.4(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2)': + '@angular-devkit/build-webpack@0.2003.4(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2(esbuild@0.25.9)))(webpack@5.101.2(esbuild@0.25.9))': dependencies: '@angular-devkit/architect': 0.2003.4(chokidar@4.0.3) rxjs: 7.8.2 webpack: 5.101.2(esbuild@0.25.9) - webpack-dev-server: 5.2.2(webpack@5.101.2) + webpack-dev-server: 5.2.2(webpack@5.101.2(esbuild@0.25.9)) transitivePeerDependencies: - chokidar @@ -12197,7 +12610,7 @@ snapshots: '@napi-rs/nice-win32-x64-msvc': 1.1.1 optional: true - '@ngtools/webpack@20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(typescript@5.9.2)(webpack@5.101.2)': + '@ngtools/webpack@20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(typescript@5.9.2)(webpack@5.101.2(esbuild@0.25.9))': dependencies: '@angular/compiler-cli': 20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2) typescript: 5.9.2 @@ -13528,6 +13941,10 @@ snapshots: dependencies: vite: 7.1.5(@types/node@22.18.8)(jiti@1.21.7)(less@4.4.0)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.3) + '@vitejs/plugin-basic-ssl@2.1.0(vite@7.1.5(@types/node@22.18.8)(jiti@1.21.7)(less@4.4.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.3))': + dependencies: + vite: 7.1.5(@types/node@22.18.8)(jiti@1.21.7)(less@4.4.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.3) + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -13899,7 +14316,7 @@ snapshots: transitivePeerDependencies: - supports-color - babel-loader@10.0.0(@babel/core@7.28.3)(webpack@5.101.2): + babel-loader@10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9)): dependencies: '@babel/core': 7.28.3 find-up: 5.0.0 @@ -14486,7 +14903,7 @@ snapshots: dependencies: is-what: 3.14.1 - copy-webpack-plugin@13.0.1(webpack@5.101.2): + copy-webpack-plugin@13.0.1(webpack@5.101.2(esbuild@0.25.9)): dependencies: glob-parent: 6.0.2 normalize-path: 3.0.0 @@ -14597,7 +15014,7 @@ snapshots: dependencies: postcss: 8.5.6 - css-loader@7.1.2(webpack@5.101.2): + css-loader@7.1.2(webpack@5.101.2(esbuild@0.25.9)): dependencies: icss-utils: 5.1.0(postcss@8.5.6) postcss: 8.5.6 @@ -17148,7 +17565,7 @@ snapshots: dependencies: readable-stream: 2.3.8 - less-loader@12.3.0(less@4.4.0)(webpack@5.101.2): + less-loader@12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9)): dependencies: less: 4.4.0 optionalDependencies: @@ -17189,7 +17606,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - license-webpack-plugin@4.0.2(webpack@5.101.2): + license-webpack-plugin@4.0.2(webpack@5.101.2(esbuild@0.25.9)): dependencies: webpack-sources: 3.3.3 optionalDependencies: @@ -17445,7 +17862,7 @@ snapshots: mimic-function@5.0.1: {} - mini-css-extract-plugin@2.9.4(webpack@5.101.2): + mini-css-extract-plugin@2.9.4(webpack@5.101.2(esbuild@0.25.9)): dependencies: schema-utils: 4.3.3 tapable: 2.3.0 @@ -18153,7 +18570,7 @@ snapshots: postcss: 8.5.6 ts-node: 10.9.2(@types/node@22.18.8)(typescript@5.7.3) - postcss-loader@8.1.1(postcss@8.5.6)(typescript@5.9.2)(webpack@5.101.2): + postcss-loader@8.1.1(postcss@8.5.6)(typescript@5.9.2)(webpack@5.101.2(esbuild@0.25.9)): dependencies: cosmiconfig: 9.0.0(typescript@5.9.2) jiti: 1.21.7 @@ -18730,7 +19147,7 @@ snapshots: safer-buffer@2.1.2: {} - sass-loader@16.0.5(sass@1.90.0)(webpack@5.101.2): + sass-loader@16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9)): dependencies: neo-async: 2.6.2 optionalDependencies: @@ -19038,7 +19455,7 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@5.0.0(webpack@5.101.2): + source-map-loader@5.0.0(webpack@5.101.2(esbuild@0.25.9)): dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 @@ -19812,6 +20229,23 @@ snapshots: terser: 5.44.0 tsx: 4.20.3 + vite@7.1.5(@types/node@22.18.8)(jiti@1.21.7)(less@4.4.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.3): + dependencies: + esbuild: 0.25.10 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.52.3 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.18.8 + fsevents: 2.3.3 + jiti: 1.21.7 + less: 4.4.2 + sass: 1.90.0 + terser: 5.44.0 + tsx: 4.20.3 + void-elements@2.0.1: {} w3c-xmlserializer@4.0.0: @@ -19848,7 +20282,7 @@ snapshots: webidl-conversions@7.0.0: {} - webpack-dev-middleware@7.4.2(webpack@5.101.2): + webpack-dev-middleware@7.4.2(webpack@5.101.2(esbuild@0.25.9)): dependencies: colorette: 2.0.20 memfs: 4.49.0 @@ -19859,7 +20293,7 @@ snapshots: optionalDependencies: webpack: 5.101.2(esbuild@0.25.9) - webpack-dev-server@5.2.2(webpack@5.101.2): + webpack-dev-server@5.2.2(webpack@5.101.2(esbuild@0.25.9)): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -19887,7 +20321,7 @@ snapshots: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.2(webpack@5.101.2) + webpack-dev-middleware: 7.4.2(webpack@5.101.2(esbuild@0.25.9)) ws: 8.18.3 optionalDependencies: webpack: 5.101.2(esbuild@0.25.9) @@ -19905,7 +20339,7 @@ snapshots: webpack-sources@3.3.3: {} - webpack-subresource-integrity@5.1.0(webpack@5.101.2): + webpack-subresource-integrity@5.1.0(webpack@5.101.2(esbuild@0.25.9)): dependencies: typed-assert: 1.0.9 webpack: 5.101.2(esbuild@0.25.9) diff --git a/testapp/public/js/webcomponent.js b/testapp/public/js/webcomponent.js new file mode 100644 index 00000000..ae059311 --- /dev/null +++ b/testapp/public/js/webcomponent.js @@ -0,0 +1,219 @@ +"use strict"; +const socket = window.io(); +let meet; +let roomId; +let showAllWebhooksCheckbox; +/** + * Add a component event to the events log + */ +const addEventToLog = (eventType, eventMessage) => { + const eventsList = document.getElementById('events-list'); + if (eventsList) { + const li = document.createElement('li'); + li.className = `event-${eventType}`; + li.textContent = `[ ${eventType} ] : ${eventMessage}`; + eventsList.insertBefore(li, eventsList.firstChild); + } +}; +const escapeHtml = (unsafe) => { + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; +const getWebhookEventsFromStorage = (roomId) => { + const data = localStorage.getItem('webhookEventsByRoom'); + if (!data) { + return []; + } + const map = JSON.parse(data); + return map[roomId] || []; +}; +const saveWebhookEventToStorage = (roomId, event) => { + const data = localStorage.getItem('webhookEventsByRoom'); + const map = data ? JSON.parse(data) : {}; + if (!map[roomId]) { + map[roomId] = []; + } + map[roomId].push(event); + localStorage.setItem('webhookEventsByRoom', JSON.stringify(map)); +}; +const clearWebhookEventsByRoom = (roomId) => { + const data = localStorage.getItem('webhookEventsByRoom'); + if (!data) + return; + const map = JSON.parse(data); + if (map[roomId]) { + map[roomId] = []; + localStorage.setItem('webhookEventsByRoom', JSON.stringify(map)); + } +}; +const shouldShowWebhook = (event) => { + return (showAllWebhooksCheckbox === null || showAllWebhooksCheckbox === void 0 ? void 0 : showAllWebhooksCheckbox.checked) || event.data.roomId === roomId; +}; +const listenWebhookServerEvents = () => { + socket.on('webhookEvent', (event) => { + console.log('Webhook received:', event); + const webhookRoomId = event.data.roomId; + if (webhookRoomId) { + saveWebhookEventToStorage(webhookRoomId, event); + } + if (!shouldShowWebhook(event)) { + console.log('Ignoring webhook event:', event); + return; + } + addWebhookEventElement(event); + // Clean up the previous events + const isMeetingEnded = event.event === 'meetingEnded'; + if (isMeetingEnded) + clearWebhookEventsByRoom(webhookRoomId); + }); +}; +const renderStoredWebhookEvents = (roomId) => { + const webhookLogList = document.getElementById('webhook-log-list'); + if (webhookLogList) { + while (webhookLogList.firstChild) { + webhookLogList.removeChild(webhookLogList.firstChild); + } + } + const events = getWebhookEventsFromStorage(roomId); + events.forEach((event) => addWebhookEventElement(event)); +}; +const addWebhookEventElement = (event) => { + const webhookLogList = document.getElementById('webhook-log-list'); + if (webhookLogList) { + // Create unique IDs for this accordion item + const itemId = event.creationDate; + const headerClassName = `webhook-${event.event}`; + const collapseId = `collapse-${itemId}`; + // Create accordion item container + const accordionItem = document.createElement('div'); + accordionItem.className = 'accordion-item'; + // Create header + const header = document.createElement('h2'); + header.classList.add(headerClassName, 'accordion-header'); + // Create header button + const button = document.createElement('button'); + button.className = 'accordion-button'; + button.type = 'button'; + button.setAttribute('data-bs-toggle', 'collapse'); + button.setAttribute('data-bs-target', `#${collapseId}`); + button.setAttribute('aria-expanded', 'true'); + button.setAttribute('aria-controls', collapseId); + button.style.padding = '10px'; + if (event.event === 'meetingStarted') { + button.classList.add('bg-success'); + } + if (event.event === 'meetingEnded') { + button.classList.add('bg-danger'); + } + if (event.event.includes('recording')) { + button.classList.add('bg-warning'); + } + // Format the header text with event name and timestamp + const date = new Date(event.creationDate); + const formattedDate = date.toLocaleString('es-ES', { + // year: 'numeric', + // month: '2-digit', + // day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + button.innerHTML = `[${formattedDate}] ${event.event}`; + // Create collapsible content container + const collapseDiv = document.createElement('div'); + collapseDiv.id = collapseId; + collapseDiv.className = 'accordion-collapse collapse'; + collapseDiv.setAttribute('aria-labelledby', headerClassName); + collapseDiv.setAttribute('data-bs-parent', '#webhook-log-list'); + // Create body content + const bodyDiv = document.createElement('div'); + bodyDiv.className = 'accordion-body'; + // Format JSON with syntax highlighting if possible + const formattedJson = JSON.stringify(event, null, 2); + bodyDiv.innerHTML = `
    ${escapeHtml(formattedJson)}
    `; + // Assemble the components + header.appendChild(button); + collapseDiv.appendChild(bodyDiv); + accordionItem.appendChild(header); + accordionItem.appendChild(collapseDiv); + // Insert at the top of the list (latest events first) + if (webhookLogList.firstChild) { + webhookLogList.insertBefore(accordionItem, webhookLogList.firstChild); + } + else { + webhookLogList.appendChild(accordionItem); + } + // Limit the number of items to prevent performance issues + const maxItems = 50; + while (webhookLogList.children.length > maxItems) { + webhookLogList.removeChild(webhookLogList.lastChild); + } + } +}; +// Listen to events from openvidu-meet +const listenWebComponentEvents = () => { + const meet = document.querySelector('openvidu-meet'); + if (!meet) { + console.error('openvidu-meet component not found'); + alert('openvidu-meet component not found in the DOM'); + return; + } + meet.on('joined', (event) => { + console.log('"joined" event received:', event); + addEventToLog('joined', JSON.stringify(event)); + }); + meet.on('left', (event) => { + console.log('"left" event received:', event); + addEventToLog('left', JSON.stringify(event)); + }); + meet.on('closed', (event) => { + console.log('"closed" event received:', event); + addEventToLog('closed', JSON.stringify(event)); + // Redirect to home page + // window.location.href = '/'; + }); +}; +// Set up commands for the web component +const setUpWebComponentCommands = () => { + var _a, _b, _c; + if (!meet) { + console.error('openvidu-meet component not found'); + alert('openvidu-meet component not found in the DOM'); + return; + } + // End meeting button click handler + (_a = document.getElementById('end-meeting-btn')) === null || _a === void 0 ? void 0 : _a.addEventListener('click', () => meet.endMeeting()); + // Leave room button click handler + (_b = document.getElementById('leave-room-btn')) === null || _b === void 0 ? void 0 : _b.addEventListener('click', () => meet.leaveRoom()); + // Kick participant button click handler + (_c = document.getElementById('kick-participant-btn')) === null || _c === void 0 ? void 0 : _c.addEventListener('click', () => { + const participantIdentity = document.getElementById('participant-identity-input').value.trim(); + if (participantIdentity) { + meet.kickParticipant(participantIdentity); + } + }); +}; +document.addEventListener('DOMContentLoaded', () => { + var _a, _b; + roomId = (_b = (_a = document.getElementById('room-id')) === null || _a === void 0 ? void 0 : _a.textContent) === null || _b === void 0 ? void 0 : _b.trim(); + showAllWebhooksCheckbox = document.getElementById('show-all-webhooks'); + meet = document.querySelector('openvidu-meet'); + if (!roomId) { + console.error('Room ID not found in the DOM'); + alert('Room ID not found in the DOM'); + return; + } + renderStoredWebhookEvents(roomId); + listenWebhookServerEvents(); + listenWebComponentEvents(); + setUpWebComponentCommands(); + showAllWebhooksCheckbox === null || showAllWebhooksCheckbox === void 0 ? void 0 : showAllWebhooksCheckbox.addEventListener('change', () => { + if (roomId) + renderStoredWebhookEvents(roomId); + }); +});