Merge branch 'e2ee_feature'

This commit is contained in:
Carlos Santos 2025-11-10 17:55:10 +01:00
commit 449f9cbf25
23 changed files with 1867 additions and 231 deletions

2
.gitignore vendored
View File

@ -37,7 +37,7 @@ pnpm-debug.log*
**/**/test-results
**/**/public/
**/backend/public/
**/*/coverage
**/**/test-results

View File

@ -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
});
/**

View File

@ -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';

View File

@ -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;
};
};

View File

@ -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;
};

View File

@ -52,8 +52,7 @@
message but will be unable to see or hear others.
</span>
</li>
<li>Recording is unavailable while encryption is enabled.</li>
<li>Chat messages are not protected by end-to-end encryption.</li>
<li>Recording is <b>unavailable</b> while encryption is enabled.</li>
</ul>
</div>
</div>

View File

@ -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
)
]
},

View File

@ -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);

View File

@ -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<void> {
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);

View File

@ -177,8 +177,25 @@ export class NavigationService {
* @param param - The parameter to remove
*/
async removeQueryParamFromUrl(queryParams: Params, param: string): Promise<void> {
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<void> {
if (!params || params.length === 0) {
return;
}
const updatedParams = { ...queryParams };
delete updatedParams[param];
params.forEach((param) => {
delete updatedParams[param];
});
await this.router.navigate([], {
queryParams: updatedParams,

View File

@ -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<string> {
async generateToken(participantOptions: ParticipantOptions, e2EEKey = ''): Promise<string> {
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<void> {
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;
}

View File

@ -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;
}

View File

@ -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"
}

View File

@ -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$': '<rootDir>/../../typings/src/index.ts',
@ -27,4 +23,4 @@ const jestConfig = {
}
}
export default jestConfig
export default config

View File

@ -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}`);
}
}
/**

View File

@ -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 1s 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()]);
});
});
// ==========================================

View File

@ -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<Locator>;
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<Locator[]>;
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<Locator> {
): Promise<Locator | Locator[]> {
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(

View File

@ -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');
});
});
});

View File

@ -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.

77
meet.sh
View File

@ -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

View File

@ -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,

494
pnpm-lock.yaml generated
View File

@ -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)

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
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}] <strong>${event.event}</strong>`;
// 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 = `<pre class="mb-0"><code>${escapeHtml(formattedJson)}</code></pre>`;
// 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);
});
});