Carlos Santos ad4ae2a69d frontend: Refactor meeting component for allowing customization
- Implemented MeetingParticipantPanelComponent for displaying participant details with moderation controls.
- Created MeetingShareLinkOverlayComponent for sharing meeting links when no participants are present.
- Developed MeetingShareLinkPanelComponent for sharing links within the participant panel.
- Introduced MeetingToolbarButtonsComponent for additional toolbar actions like copying links and leaving meetings.
- Refactored MeetingComponent to utilize new components for participant management and sharing links.
- Updated styles for new components and removed redundant styles from MeetingComponent.

frontend: implement CE-specific meeting component with routing and UI elements

frontend: Enhance meeting components with plugin system

- Added alternative function inputs for event handling in MeetingLobbyComponent, MeetingParticipantPanelComponent, MeetingShareLinkOverlayComponent, MeetingShareLinkPanelComponent, and MeetingToolbarButtonsComponent.
- Introduced MeetingComponentsPlugins interface and MEETING_COMPONENTS_TOKEN for dynamic component injection.
- Updated MeetingComponent to utilize NgComponentOutlet for rendering plugins and prepare inputs for plugin components.
- Removed CE-specific MeetingCEComponent and its associated files, integrating its functionality directly into MeetingComponent.
- Created MEETING_CE_PROVIDERS to configure the plugin system using library components directly.
- Updated routing to use the new MeetingComponent with plugin support.

frontend: Update meeting component to display prejoin screen with lobby plugin

Moves meeting service to a subdirectory

Moves the meeting service to its own subdirectory for better organization.

Updates imports to reflect the new location.

frontend: Refactor dialog component to conditionally render action buttons

frontend: Implement lobby state management and enhance prejoin screen functionality

frontend: Refactor MeetingComponent to streamline service injections and constructor

frontend: Remove unused participantToken variable and add getter for lobbyState participantToken

frontend: Rename lobby.service to meeting-lobby.service

frontend: Refactor MeetingComponent to use MeetingPluginManagerService for plugin inputs and remove deprecated methods

meet.sh: launch testapp with dev command

backend: Added webhook config in .env.test

Adds web component events e2e tests

Introduces end-to-end tests for web component events, covering scenarios such as joining, leaving, and handling meeting closure.

The tests verify correct event emission and payload structure, including reason codes for leave events.

Also, add `test_localstorage_state.json` to git ignore, removing the file.

frontend: Added meeting event handler service

frontend: Enhances meeting component reactivity

Refactors the meeting component to use signals for reactive updates.

This improves performance by reducing unnecessary re-renders and simplifies state management.

- Moves event handling to a dedicated service.
- Introduces signals for participant lists and updates.
- Implements caching for participant panel inputs.
- Improves moderator control visibility logic.

webcomponent: Added moderation e2e tests

refactor(meeting): optimize participant panel item inputs handling

frontend: fix moderator badge rendering in participant panel

refactor(meeting): remove unused services and streamline constructor logic

refactor(meeting): update leave and end meeting handlers to return promises
2025-10-31 13:16:43 +01:00

549 lines
19 KiB
TypeScript

import { expect, FrameLocator, Locator, Page } from '@playwright/test';
import * as fs from 'fs';
import { PNG } from 'pngjs';
import { MeetRecordingAccess, MeetRoomConfig } from '../../../../typings/src/room-config';
import { MEET_API_KEY, MEET_API_URL, MEET_TESTAPP_URL } from '../config';
/**
* Gets a FrameLocator for an iframe inside a Shadow DOM
* @param page - Playwright page object
* @param componentSelector - Selector for the web component with Shadow DOM
* @param iframeSelector - Selector for the iframe within the Shadow DOM
* @returns FrameLocator that can be used to access iframe contents
*/
export async function getIframeInShadowDom(
page: Page,
componentSelector: string = 'openvidu-meet',
iframeSelector: string = 'iframe'
): Promise<FrameLocator> {
// Verify the component exists
await page.waitForSelector(componentSelector);
// Use frameLocator to access the iframe contents
return page.frameLocator(`${componentSelector} >>> ${iframeSelector}`);
}
/**
* 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
*/
export async function waitForElementInIframe(
page: Page,
elementSelector: string,
options: {
componentSelector?: string;
iframeSelector?: string;
timeout?: number;
state?: 'attached' | 'detached' | 'visible' | 'hidden';
} = {}
): Promise<Locator> {
const {
componentSelector = 'openvidu-meet',
iframeSelector = 'iframe',
timeout = 30000,
state = 'visible'
} = options;
// Get the iframe
const frameLocator = await getIframeInShadowDom(page, componentSelector, iframeSelector);
// Get element locator
const elementLocator = frameLocator.locator(elementSelector);
// Wait for the element with the specified state
await elementLocator.waitFor({ state, timeout });
return elementLocator;
}
// Interact with an element inside an iframe within Shadow DOM
export async function interactWithElementInIframe(
page: Page,
elementSelector: string,
options: {
action: 'click' | 'fill' | 'type';
value?: string; // Only needed for 'fill' or 'type' actions
timeout?: number;
} = {
action: 'click',
value: '',
timeout: 30000
}
): Promise<void> {
const { action, value = '', timeout = 30000 } = options;
const element = await waitForElementInIframe(page, elementSelector, { timeout });
// Perform the specified action
switch (action) {
case 'click':
await element.click();
break;
case 'fill':
await element.fill(value);
break;
default:
throw new Error(`Unsupported action: ${action}`);
}
}
// Helper function to get default room config
const getDefaultRoomConfig = (): MeetRoomConfig => ({
recording: {
enabled: true,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true }
});
// Helper function to create a room for testing
export const createTestRoom = async (roomName: string, config: MeetRoomConfig = getDefaultRoomConfig()) => {
const response = await fetch(`${MEET_API_URL}/api/v1/rooms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': MEET_API_KEY
},
body: JSON.stringify({
roomName,
autoDeletionDate: new Date(Date.now() + 61 * 60 * 1000).getTime(), // 1 hour from now
config
})
});
if (!response.ok) {
const errorResponse = await response.json();
console.error('Error creating room:', errorResponse);
throw new Error(`Failed to create room: ${response.status}`);
}
const room = await response.json();
return room.roomId;
};
// Helper function to update room config via REST API
export const updateRoomConfig = async (roomId: string, config: any) => {
const response = await fetch(`${MEET_API_URL}/api/v1/rooms/${roomId}/config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-api-key': MEET_API_KEY
},
body: JSON.stringify({ config })
});
if (!response.ok) {
throw new Error(`Failed to update room config: ${response.status} ${await response.text()}`);
}
return response.json();
};
// Helper function to delete a room
export const deleteTestRoom = async (roomId: string) => {
await fetch(`${MEET_API_URL}/api/v1/rooms/${roomId}`, {
method: 'DELETE',
headers: {
'x-api-key': MEET_API_KEY
}
});
};
export const deleteAllRecordings = async (page: Page) => {
await page.goto(MEET_TESTAPP_URL);
await page.waitForSelector('#delete-all-recordings-btn', { state: 'visible' });
await page.click('#delete-all-recordings-btn');
};
export const deleteAllRooms = async (page: Page) => {
await page.goto(MEET_TESTAPP_URL);
await page.waitForSelector('#delete-all-rooms-btn', { state: 'visible' });
await page.click('#delete-all-rooms-btn');
};
export const startStopRecording = async (page: Page, action: 'start' | 'stop') => {
const buttonSelector = action === 'start' ? '#recording-btn' : '#stop-recording-btn';
if (action === 'start') {
await openMoreOptionsMenu(page);
}
await waitForElementInIframe(page, buttonSelector, { state: 'visible' });
await interactWithElementInIframe(page, buttonSelector, { action: 'click' });
await page.waitForTimeout(500); // Wait for recording action to complete
if (action === 'start') {
await page.waitForSelector('.webhook-recordingUpdated', { timeout: 10000 });
}
if (action === 'stop') {
await page.waitForSelector('.webhook-recordingEnded', { timeout: 10000 });
}
};
export const prepareForJoiningRoom = async (page: Page, url: string, roomId: string) => {
await page.goto(url);
await page.waitForSelector('.rooms-container');
await page.waitForSelector(`#${roomId}`);
await page.click('.dropdown-button');
await page.waitForSelector('#join-as-moderator');
await page.waitForSelector('#join-as-speaker');
};
export const joinRoomAs = async (role: 'moderator' | 'speaker', pName: string, page: Page) => {
await page.click('#join-as-' + role);
const component = page.locator('openvidu-meet');
await expect(component).toBeVisible();
// Wait for participant name input and fill it
await waitForElementInIframe(page, '#participant-name-input', { state: 'visible' });
await interactWithElementInIframe(page, '#participant-name-input', {
action: 'fill',
value: pName
});
await interactWithElementInIframe(page, '#participant-name-submit', { action: 'click' });
// wait for prejoin page to load and join the room
await waitForElementInIframe(page, 'ov-pre-join', { state: 'visible' });
await interactWithElementInIframe(page, '#join-button', { action: 'click' });
await waitForElementInIframe(page, 'ov-session', { state: 'visible' });
};
export const accessRoomAs = async (role: 'moderator' | 'speaker', page: Page) => {
await page.click('#join-as-' + role);
const component = page.locator('openvidu-meet');
await expect(component).toBeVisible();
};
export const viewRecordingsAs = async (role: 'moderator' | 'speaker', page: Page) => {
await page.click('#join-as-' + role);
const component = page.locator('openvidu-meet');
await expect(component).toBeVisible();
await interactWithElementInIframe(page, '#view-recordings-btn', { action: 'click' });
};
export const leaveRoom = async (page: Page, role: 'moderator' | 'speaker' = 'speaker') => {
await interactWithElementInIframe(page, '#leave-btn', { action: 'click' });
if (role === 'moderator') {
await page.waitForTimeout(500); // Wait for leave animation
await interactWithElementInIframe(page, '#leave-option', { action: 'click' });
}
await page.waitForSelector('.event-left');
};
export const startScreenSharing = async (page: Page) => {
await interactWithElementInIframe(page, '#screenshare-btn', { action: 'click' });
await waitForElementInIframe(page, '#local-element-screen_share', { state: 'visible' });
};
export const stopScreenSharing = async (page: Page) => {
await interactWithElementInIframe(page, '#screenshare-btn', { action: 'click' });
await page.waitForTimeout(200); // Wait for screen menu
await interactWithElementInIframe(page, '#disable-screen-button', { action: 'click' });
await page.waitForTimeout(500); // Wait for screen to stop sharing
};
export const applyVirtualBackground = async (page: Page, backgroundId: string) => {
await interactWithElementInIframe(page, '#more-options-btn', { action: 'click' });
await page.waitForTimeout(500);
await interactWithElementInIframe(page, '#virtual-bg-btn', { action: 'click' });
await waitForElementInIframe(page, 'ov-background-effects-panel', { state: 'visible' });
await interactWithElementInIframe(page, `#effect-${backgroundId}`, { action: 'click' });
await page.waitForTimeout(2000); // Allow background processing time
await interactWithElementInIframe(page, '.panel-close-button', { action: 'click' });
await page.waitForTimeout(1000); // Wait panel to close
};
export const removeVirtualBackground = async (page: Page) => {
await interactWithElementInIframe(page, '#more-options-btn', { action: 'click' });
await page.waitForTimeout(500);
await interactWithElementInIframe(page, '#virtual-bg-btn', { action: 'click' });
await interactWithElementInIframe(page, '#no_effect-btn', { action: 'click' });
await page.waitForTimeout(500); // Wait for background to be removed
};
/**
* Analyzes the current video frame to determine if the virtual background has been applied
* by checking if most pixels are different from Chrome's synthetic green background
*/
export const isVirtualBackgroundApplied = async (
page: Page,
videoSelector: string = '.OV_video-element',
options: {
minChangedPixelsPercent?: number; // Minimum % of non-green pixels to consider background applied
saveDebugImages?: boolean; // Save images for debugging
} = {}
): Promise<boolean> => {
const {
minChangedPixelsPercent = 70, // At least 20% of pixels should be non-green
saveDebugImages = false
} = options;
try {
// Capture current video frame
const screenshotPath = `test-results/vbg_check_${Date.now()}.png`;
await saveScreenshot(page, screenshotPath, videoSelector);
// Read the captured image
const currentFrame = PNG.sync.read(fs.readFileSync(screenshotPath));
const { width, height } = currentFrame;
// Count green pixels (sample every 5th pixel for performance)
let greenPixels = 0;
let totalSampled = 0;
for (let y = 0; y < height; y += 5) {
for (let x = 0; x < width; x += 5) {
const idx = (width * y + x) << 2;
const r = currentFrame.data[idx];
const g = currentFrame.data[idx + 1];
const b = currentFrame.data[idx + 2];
totalSampled++;
if (isChromeSyntheticGreen(r, g, b)) {
greenPixels++;
}
}
}
const greenPercentage = (greenPixels / totalSampled) * 100;
const nonGreenPercentage = 100 - greenPercentage;
const backgroundApplied = nonGreenPercentage >= minChangedPixelsPercent;
console.log(
`Video Analysis: ${nonGreenPercentage.toFixed(1)}% non-green pixels - Background applied: ${backgroundApplied}`
);
// Cleanup
if (!saveDebugImages) {
fs.unlinkSync(screenshotPath);
}
return backgroundApplied;
} catch (error) {
console.error('Error checking virtual background:', error);
return false;
}
};
/**
* Detects if a pixel is part of Chrome's synthetic green screen
* Chrome's fake video can vary, but typically has these characteristics:
* - Green channel is dominant
* - Overall brightness suggests synthetic content
* - Color tends to be uniformly distributed
*/
const isChromeSyntheticGreen = (r: number, g: number, b: number): boolean => {
// Method 1: Classic bright green detection (loose tolerances)
const isBrightGreen = g > 150 && g > r + 50 && g > b + 50 && r < 100 && b < 100;
// Method 2: Detect greenish hues with high saturation
const isGreenish = g > Math.max(r, b) && g > 100;
const hasLowRedBlue = r + b < g * 0.6;
const isGreenDominant = isGreenish && hasLowRedBlue;
// Method 3: Check for uniform synthetic-looking colors
// Chrome often uses specific green values
const isTypicalChromeGreen =
(g >= 240 && r <= 50 && b <= 50) || // Very bright green
(g >= 200 && r <= 80 && b <= 80) || // Bright green
(g >= 160 && r <= 60 && b <= 60); // Medium green
// Method 4: HSV-like check - high green saturation
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const saturation = max > 0 ? (max - min) / max : 0;
const isHighSaturationGreen = g === max && saturation > 0.5 && g > 120;
// Return true if any of the methods detect green
return isBrightGreen || isGreenDominant || isTypicalChromeGreen || isHighSaturationGreen;
};
/**
* Helper function that waits for virtual background to be applied
*/
export const waitForVirtualBackgroundToApply = async (page: Page, maxWaitTime: number = 5000): Promise<boolean> => {
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
const isApplied = await isVirtualBackgroundApplied(page);
if (isApplied) {
console.log('✅ Virtual background detected');
return true;
}
await page.waitForTimeout(500); // Check every 500ms
}
console.log('❌ Virtual background not detected after waiting');
return false;
};
export const saveScreenshot = async (page: Page, filename: string, selector: string) => {
const element = await waitForElementInIframe(page, selector);
await element.screenshot({ path: filename });
};
export const openMoreOptionsMenu = async (page: Page) => {
await waitForElementInIframe(page, '#toolbar', { state: 'visible' });
// Open more options menu
await interactWithElementInIframe(page, '#more-options-btn', { action: 'click' });
await page.waitForTimeout(500); // Wait for menu animation
};
export const closeMoreOptionsMenu = async (page: Page) => {
await interactWithElementInIframe(page, 'body', { action: 'click' });
await page.waitForTimeout(500); // Wait for menu to close
};
// ==========================================
// MODERATION HELPER FUNCTIONS
// ==========================================
/**
* Gets the participant ID (sid) of a participant by name from a specific page view
* @param page - Playwright page object
* @param participantName - Name of the participant to find
* @returns Promise resolving to the participant ID (sid) or empty string if not found
*/
export const getParticipantIdByName = async (page: Page, participantName: string): Promise<string> => {
// Get iframe using the proper Playwright method
const frameLocator = await getIframeInShadowDom(page);
// Find all participant containers
const participantContainers = frameLocator.locator('[data-participant-id]');
const count = await participantContainers.count();
console.log(`🔍 Found ${count} participant containers`);
// Iterate through participants to find the matching name
for (let i = 0; i < count; i++) {
const container = participantContainers.nth(i);
const nameElement = container.locator('.participant-name-text');
const pName = await nameElement.textContent();
const pId = await container.getAttribute('data-participant-id');
console.log(`👤 Participant: "${pName?.trim()}" with ID: ${pId}`);
if (pName?.trim() === participantName) {
console.log(`✅ Found matching participant: ${participantName} with ID: ${pId}`);
return pId || '';
}
}
console.log(`❌ Could not find participant with name: ${participantName}`);
return '';
};
/**
* Gets the current user's own participant ID (sid)
* @param page - Playwright page object
* @returns Promise resolving to the local participant's ID (sid) or empty string if not found
*/
export const getLocalParticipantId = async (page: Page): Promise<string> => {
// Get iframe using the proper Playwright method
const frameLocator = await getIframeInShadowDom(page);
// Find all participant containers
const participantContainers = frameLocator.locator('[data-participant-id]');
const count = await participantContainers.count();
console.log(`🔍 Found ${count} participant containers`);
// Iterate through participants to find the local one (has .local-indicator)
for (let i = 0; i < count; i++) {
const container = participantContainers.nth(i);
const youLabel = container.locator('.local-indicator');
const hasYouLabel = (await youLabel.count()) > 0;
if (hasYouLabel) {
const nameElement = container.locator('.participant-name-text');
const participantName = await nameElement.textContent();
const pId = await container.getAttribute('data-participant-id');
console.log(`✅ Found local participant: "${participantName?.trim()}" with ID: ${pId}`);
return pId || '';
}
}
console.log('❌ Could not find local participant');
return '';
};
/**
* Opens the participants panel and waits for it to be visible
* @param page - Playwright page object
*/
export const openParticipantsPanel = async (page: Page): Promise<void> => {
await waitForElementInIframe(page, '#participants-panel-btn');
await interactWithElementInIframe(page, '#participants-panel-btn', { action: 'click' });
await waitForElementInIframe(page, 'ov-participants-panel', { state: 'visible' });
await page.waitForTimeout(1000); // Wait for panel to fully load
};
/**
* Makes a participant a moderator by clicking the make-moderator button
* @param page - Playwright page object (moderator's page)
* @param participantId - The participant ID (sid) to promote
*/
export const makeParticipantModerator = async (page: Page, participantId: string): Promise<void> => {
const makeModeratorbtn = await waitForElementInIframe(page, `#make-moderator-btn-${participantId}`, {
state: 'visible',
timeout: 10000
});
await makeModeratorbtn.click();
await page.waitForTimeout(2000); // Wait for role change to propagate
};
/**
* Removes moderator role from a participant by clicking the remove-moderator button
* @param page - Playwright page object (moderator's page)
* @param participantId - The participant ID (sid) to demote
*/
export const removeParticipantModerator = async (page: Page, participantId: string): Promise<void> => {
const removeModeratorbtn = await waitForElementInIframe(page, `#remove-moderator-btn-${participantId}`, {
state: 'visible',
timeout: 10000
});
await removeModeratorbtn.click();
await page.waitForTimeout(2000); // Wait for role change to propagate
};
/**
* Checks if an overlay element is hidden in the iframe
* An element is considered hidden if:
* - It doesn't exist in the DOM (removed)
* - Has display: none
* - Has visibility: hidden
* - Has opacity: 0
* @param page - Playwright page object
* @param overlaySelector - CSS selector for the overlay element
* @returns Promise resolving to true if the overlay is hidden, false otherwise
*/
export const isShareLinkOverlayyHidden = async (page: Page, overlaySelector: string): Promise<boolean> => {
const frameLocator = await getIframeInShadowDom(page);
const overlay = frameLocator.locator(overlaySelector);
const count = await overlay.count();
// Element doesn't exist in the DOM
if (count === 0) {
console.log('✅ Overlay element not found in DOM (removed)');
return true;
}
// Check if element is hidden via CSS
const isVisible = await overlay.isVisible().catch(() => false);
if (!isVisible) {
console.log('✅ Overlay is hidden (display: none, visibility: hidden, or opacity: 0)');
return true;
}
console.log('❌ Overlay is still visible');
return false;
};