testapp: add functionality to delete all rooms and add core tests

This commit is contained in:
Carlos Santos 2025-06-06 12:32:10 +02:00
parent be7a37a004
commit 64789951d3
9 changed files with 2968 additions and 2483 deletions

View File

@ -5,6 +5,64 @@ on:
workflow_dispatch:
jobs:
e2e-test-core:
name: E2E Tests Core
runs-on: ov-actions-runner
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup OpenVidu Local Deployment
uses: OpenVidu/actions/start-openvidu-local-deployment@main
- name: Setup OpenVidu Meet
uses: OpenVidu/actions/start-openvidu-meet@main
env:
MEET_WEBHOOK_ENABLED: true
- name: Start testapp
shell: bash
run: |
cd testapp
npm run start > ../testapp.log 2>&1 &
- name: Wait for testapp to Start
shell: bash
run: |
echo "Waiting for testapp to start on http://localhost:5080..."
for i in {1..30}; do
if curl -s http://localhost:5080 >/dev/null 2>&1; then
echo "Testapp is ready!"
exit 0
fi
echo "Attempt $i/30: Testapp not ready yet, waiting 1 second..."
sleep 1
done
echo "Timeout: Testapp failed to start within 30 seconds"
exit 1
- name: Run tests
run: |
cd frontend/webcomponent
# Install Playwright browsers
mkdir -p /tmp/ms-playwright
PLAYWRIGHT_BROWSERS_PATH=/tmp/ms-playwright npx playwright install --with-deps chromium
npm run test:e2e-core
env:
RUN_MODE: CI
PLAYWRIGHT_BROWSERS_PATH: /tmp/ms-playwright
- name: Upload failed test videos
if: always()
uses: actions/upload-artifact@v4
with:
name: test-videos
path: |
frontend/webcomponent/test-results/*/*.webm
retention-days: 2
- name: Clean up
if: always()
uses: OpenVidu/actions/cleanup@main
webcomponent-e2e-test:
name: WebComponent E2E Tests
runs-on: ov-actions-runner

View File

@ -0,0 +1,278 @@
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
import {
applyVirtualBackground,
interactWithElementInIframe,
joinRoomAs,
leaveRoom,
prepareForJoiningRoom,
saveScreenshot,
startScreenSharing,
stopScreenSharing,
waitForElementInIframe
} from '../../helpers/function-helpers.js';
let subscribedToAppErrors = false;
test.describe('Room Functionality Tests', () => {
const testAppUrl = 'http://localhost:5080';
const testRoomPrefix = 'testing-room';
// ==========================================
// SETUP & TEARDOWN
// ==========================================
test.beforeAll(async ({ browser }) => {
// Create a test room before all tests
const tempContext = await browser.newContext();
const tempPage = await tempContext.newPage();
await tempPage.goto(testAppUrl);
await tempPage.waitForSelector('.create-room');
await tempPage.fill('#room-id-prefix', testRoomPrefix);
await tempPage.click('.create-room-btn');
await tempPage.waitForSelector(`#${testRoomPrefix}`);
await tempPage.close();
await tempContext.close();
});
test.beforeEach(async ({ page }) => {
if (!subscribedToAppErrors) {
page.on('console', (msg) => {
const type = msg.type();
const tag = type === 'error' ? 'ERROR' : type === 'warning' ? 'WARNING' : 'LOG';
console.log('[' + tag + ']', msg.text());
});
subscribedToAppErrors = true;
}
await prepareForJoiningRoom(page, testAppUrl, testRoomPrefix);
});
test.afterEach(async ({ context }) => {
// Save storage state after each test
await context.storageState({ path: 'test_localstorage_state.json' });
});
test.afterAll(async ({ browser }) => {
// Cleanup: delete the test room
const tempContext = await browser.newContext();
const tempPage = await tempContext.newPage();
await tempPage.goto(testAppUrl);
await tempPage.waitForSelector('#delete-all-rooms');
await tempPage.click('#delete-all-rooms');
await tempPage.close();
await tempContext.close();
});
// ==========================================
// BASIC FUNCTIONALITY TESTS
// ==========================================
test.describe('Basic Room Features', () => {
test('should show the toolbar and media buttons', async ({ page }) => {
await joinRoomAs('publisher', page);
await waitForElementInIframe(page, '#toolbar');
// Check media buttons are present
await waitForElementInIframe(page, '#camera-btn');
await waitForElementInIframe(page, '#mic-btn');
await leaveRoom(page);
});
test('should start a videoconference and display video elements', async ({ page, browser }) => {
// First participant joins
await joinRoomAs('publisher', page);
await waitForElementInIframe(page, 'ov-session');
// Check local video element
const localVideo = await waitForElementInIframe(page, '.OV_stream.local');
await expect(localVideo).toBeVisible();
// Second participant (moderator) joins
const context = await browser.newContext();
const moderatorPage = await context.newPage();
await prepareForJoiningRoom(moderatorPage, testAppUrl, testRoomPrefix);
await joinRoomAs('moderator', moderatorPage);
await waitForElementInIframe(moderatorPage, 'ov-participant-name-form');
// Set moderator name
await interactWithElementInIframe(moderatorPage, '#participant-name-input', {
action: 'fill',
value: 'Moderator'
});
await interactWithElementInIframe(moderatorPage, '#participant-name-submit', { action: 'click' });
// Verify session established and remote video appears
await waitForElementInIframe(moderatorPage, 'ov-session');
await waitForElementInIframe(moderatorPage, '.OV_stream.remote');
// Cleanup
await leaveRoom(page);
await leaveRoom(moderatorPage);
await context.close();
});
});
// ==========================================
// SCREEN SHARING TESTS
// ==========================================
test.describe('Screen Sharing', () => {
test('should be able to share and stop screen sharing', async ({ page }) => {
await joinRoomAs('publisher', page);
await waitForElementInIframe(page, '#toolbar');
// Initial state: only camera video
let videoCount = await page.frameLocator('iframe').locator('video').count();
expect(videoCount).toBe(1);
// Enable screen share
await startScreenSharing(page);
videoCount = await page.frameLocator('iframe').locator('video').count();
expect(videoCount).toBe(2);
// Disable screen share
await stopScreenSharing(page);
videoCount = await page.frameLocator('iframe').locator('video').count();
expect(videoCount).toBe(1);
// Test toggle functionality
await startScreenSharing(page);
videoCount = await page.frameLocator('iframe').locator('video').count();
expect(videoCount).toBe(2);
await stopScreenSharing(page);
videoCount = await page.frameLocator('iframe').locator('video').count();
expect(videoCount).toBe(1);
await leaveRoom(page);
});
});
// ==========================================
// UI PANELS TESTS
// ==========================================
test.describe('UI Panels and Components', () => {
test('should show and interact with chat panel', async ({ page }) => {
await joinRoomAs('publisher', page);
await waitForElementInIframe(page, 'ov-session');
// Open chat panel
await waitForElementInIframe(page, '#chat-panel-btn');
await interactWithElementInIframe(page, '#chat-panel-btn', { action: 'click' });
// Send a message
await waitForElementInIframe(page, '#chat-input');
await interactWithElementInIframe(page, '#chat-input', {
action: 'fill',
value: 'Hello world'
});
await interactWithElementInIframe(page, '#send-btn', { action: 'click' });
// Verify message appears
const chatMessage = await waitForElementInIframe(page, '.chat-message');
await expect(chatMessage).toBeVisible();
await leaveRoom(page);
});
test('should show activities panel', async ({ page }) => {
await joinRoomAs('publisher', page);
await waitForElementInIframe(page, 'ov-session');
// Open activities panel
await waitForElementInIframe(page, '#activities-panel-btn');
await interactWithElementInIframe(page, '#activities-panel-btn', { action: 'click' });
// Verify panel is visible
const activitiesPanel = await waitForElementInIframe(page, 'ov-activities-panel');
await expect(activitiesPanel).toBeVisible();
await leaveRoom(page);
});
test('should show participants panel', async ({ page }) => {
await joinRoomAs('publisher', page);
await waitForElementInIframe(page, 'ov-session');
// Open participants panel
await waitForElementInIframe(page, '#participants-panel-btn');
await interactWithElementInIframe(page, '#participants-panel-btn', { action: 'click' });
// Verify panel is visible
const participantsPanel = await waitForElementInIframe(page, 'ov-participants-panel');
await expect(participantsPanel).toBeVisible();
await leaveRoom(page);
});
test('should show settings panel', async ({ page }) => {
await joinRoomAs('publisher', page);
await waitForElementInIframe(page, 'ov-session');
// Open more options menu
await interactWithElementInIframe(page, '#more-options-btn', { action: 'click' });
await page.waitForTimeout(500); // Wait for menu animation
// Open settings panel
await interactWithElementInIframe(page, '#toolbar-settings-btn', { action: 'click' });
// Verify panel is visible
const settingsPanel = await waitForElementInIframe(page, 'ov-settings-panel');
await expect(settingsPanel).toBeVisible();
await leaveRoom(page);
});
});
// ==========================================
// ADVANCED FEATURES TESTS
// ==========================================
test.describe('Advanced Features', () => {
test('should apply virtual background and detect visual changes', async ({ page }) => {
await joinRoomAs('publisher', page);
await waitForElementInIframe(page, 'ov-session');
// Wait for video element to be ready
await waitForElementInIframe(page, '.OV_video-element');
// Capture baseline screenshot
await saveScreenshot(page, 'before.png', '.OV_video-element');
// Apply virtual background
await applyVirtualBackground(page, '2');
await page.waitForTimeout(1000); // Allow background processing time
// Capture post-change screenshot
await saveScreenshot(page, 'after.png', '.OV_video-element');
// Compare images to detect changes
const img1 = PNG.sync.read(fs.readFileSync('before.png'));
const img2 = PNG.sync.read(fs.readFileSync('after.png'));
const { width, height } = img1;
const diff = new PNG({ width, height });
const numDiffPixels = pixelmatch(img1.data, img2.data, diff.data, width, height, {
threshold: 0.4
});
// Save diff for debugging purposes
fs.writeFileSync('diff.png', PNG.sync.write(diff));
// Verify significant visual change occurred
expect(numDiffPixels).toBeGreaterThan(500);
// Cleanup test artifacts
fs.unlinkSync('before.png');
fs.unlinkSync('after.png');
fs.unlinkSync('diff.png');
await leaveRoom(page);
});
});
});

View File

@ -1,4 +1,5 @@
import { Page, Locator, FrameLocator } from '@playwright/test';
import { expect } from '@playwright/test';
/**
* Gets a FrameLocator for an iframe inside a Shadow DOM
@ -54,3 +55,86 @@ export async function waitForElementInIframe(
return elementLocator;
}
// Interacti 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);
// 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}`);
}
}
export const prepareForJoiningRoom = async (page: Page, url: string, roomPrefix: string) => {
await page.goto(url);
await page.waitForSelector('.rooms-container');
await page.waitForSelector(`#${roomPrefix}`);
await page.click('.dropdown-button');
await page.waitForSelector('#join-as-moderator');
await page.waitForSelector('#join-as-publisher');
};
export const joinRoomAs = async (role: 'moderator' | 'publisher', page: Page) => {
await page.click('#join-as-' + role);
const component = page.locator('openvidu-meet');
await expect(component).toBeVisible();
};
export const leaveRoom = async (page: Page) => {
const button = await waitForElementInIframe(page, '#leave-btn');
await button.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 interactWithElementInIframe(page, '.panel-close-button', { action: 'click' });
};
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
};
export const saveScreenshot = async (page: Page, filename: string, selector: string) => {
const element = await waitForElementInIframe(page, selector);
await element.screenshot({ path: filename });
};

View File

@ -21,6 +21,13 @@
</div>
<div class="rooms-list">
<div class="mb-3 text-center">
<form action="/delete-all-rooms" method="post">
<button type="submit" class="btn btn-danger btn-sm" id="delete-all-rooms">
Delete All Rooms
</button>
</form>
</div>
{{#rooms.length}}
<ul class="list-group">
{{#rooms}}
@ -50,13 +57,13 @@
name="participantRole"
value="moderator"
/>
<input
type="hidden"
name="roomId"
value="{{ roomId }}"
/>
<input type="hidden" name="roomId" value="{{ roomId }}" />
<button type="submit" id="join-as-moderator" class="dropdown-item">
<button
type="submit"
id="join-as-moderator"
class="dropdown-item"
>
Moderator
</button>
</form>
@ -73,13 +80,13 @@
name="participantRole"
value="publisher"
/>
<input
type="hidden"
name="roomId"
value="{{ roomId }}"
/>
<input type="hidden" name="roomId" value="{{ roomId }}" />
<button type="submit" id="join-as-publisher" class="dropdown-item">
<button
type="submit"
id="join-as-publisher"
class="dropdown-item"
>
Publisher
</button>
</form>
@ -97,13 +104,13 @@
name="participantRole"
value="publisher"
/>
<input
type="hidden"
name="roomId"
value="{{ roomId }}"
/>
<input type="hidden" name="roomId" value="{{ roomId }}" />
<button type="submit" id="join-as-publisher" class="dropdown-item">
<button
type="submit"
id="join-as-publisher"
class="dropdown-item"
>
View Recordings
</button>
</form>

View File

@ -1,5 +1,10 @@
import { Request, Response } from 'express';
import { getAllRooms, createRoom, deleteRoom } from '../services/roomService';
import {
getAllRooms,
createRoom,
deleteRoom,
deleteAllRooms,
} from '../services/roomService';
export const getHome = async (req: Request, res: Response) => {
try {
@ -33,7 +38,7 @@ export const postCreateRoom = async (req: Request, res: Response) => {
}
};
export const postDeleteRoom = async (req: Request, res: Response) => {
export const deleteRoomCtrl = async (req: Request, res: Response) => {
try {
const { roomId } = req.body;
await deleteRoom(roomId);
@ -44,3 +49,21 @@ export const postDeleteRoom = async (req: Request, res: Response) => {
return;
}
};
export const deleteAllRoomsCtrl = async (_req: Request, res: Response) => {
try {
const allRooms = await getAllRooms();
if (allRooms.rooms.length === 0) {
console.log('No rooms to delete');
res.render('index', { rooms: [] });
return;
}
const roomIds = allRooms.rooms.map((room) => room.roomId);
await deleteAllRooms(roomIds);
res.render('index', { rooms: [] });
} catch (error) {
console.error('Error deleting all rooms:', error);
res.status(500).send('Internal Server Error ' + JSON.stringify(error));
return;
}
};

View File

@ -5,7 +5,8 @@ import path from 'path';
import {
getHome,
postCreateRoom,
postDeleteRoom,
deleteRoomCtrl,
deleteAllRoomsCtrl,
} from './controllers/homeController';
import { handleWebhook, joinRoom } from './controllers/roomController';
import { configService } from './services/configService';
@ -31,7 +32,8 @@ app.use(express.json());
app.get('/', getHome);
app.get('/room', joinRoom);
app.post('/room', postCreateRoom);
app.post('/room/delete', postDeleteRoom);
app.post('/room/delete', deleteRoomCtrl);
app.post('/delete-all-rooms', deleteAllRoomsCtrl);
app.post('/join-room', joinRoom);
app.post('/webhook', (req, res) => {
handleWebhook(req, res, io);

View File

@ -3,9 +3,12 @@ import { configService } from './configService';
// @ts-ignore
import { MeetRoom, MeetRoomOptions } from '../../../typings/src/room';
export async function getAllRooms(): Promise<{pagination:any, rooms: MeetRoom[]}> {
export async function getAllRooms(): Promise<{
pagination: any;
rooms: MeetRoom[];
}> {
const url = `${configService.meetApiUrl}/rooms`;
return get<{pagination:any, rooms: MeetRoom[]}>(url, {
return get<{ pagination: any; rooms: MeetRoom[] }>(url, {
headers: { 'x-api-key': configService.apiKey },
});
}
@ -13,7 +16,8 @@ export async function getAllRooms(): Promise<{pagination:any, rooms: MeetRoom[]}
export async function createRoom(roomData: MeetRoomOptions): Promise<MeetRoom> {
const url = `${configService.meetApiUrl}/rooms`;
// Default to 1 hour if autoDeletionDate is not provided
if(!roomData.autoDeletionDate) roomData.autoDeletionDate = new Date(Date.now() + 60 * 61 * 1000).getTime();
if (!roomData.autoDeletionDate)
roomData.autoDeletionDate = new Date(Date.now() + 60 * 61 * 1000).getTime();
console.log('Creating room with options:', roomData);
return post<MeetRoom>(url, {
headers: { 'x-api-key': configService.apiKey },
@ -27,3 +31,11 @@ export async function deleteRoom(roomId: string): Promise<void> {
headers: { 'x-api-key': configService.apiKey },
});
}
export async function deleteAllRooms(roomIds: string[]): Promise<void> {
const url = `${configService.meetApiUrl}/rooms?roomIds=${roomIds.join(',')}`;
await del<void>(url, {
headers: { 'x-api-key': configService.apiKey },
});
}

View File

@ -4,18 +4,27 @@ export interface RequestOptions {
body?: any;
}
async function buildUrl(url: string, queryParams?: Record<string, string>): Promise<string> {
async function buildUrl(
url: string,
queryParams?: Record<string, string>
): Promise<string> {
if (!queryParams) return url;
const params = new URLSearchParams(queryParams as Record<string, string>).toString();
const params = new URLSearchParams(
queryParams as Record<string, string>
).toString();
return `${url}?${params}`;
}
async function request<T>(method: string, url: string, options: RequestOptions = {}): Promise<T> {
async function request<T>(
method: string,
url: string,
options: RequestOptions = {}
): Promise<T> {
const fullUrl = await buildUrl(url, options.queryParams);
const fetchOptions: RequestInit = {
method,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
body: options.body ? JSON.stringify(options.body) : undefined
body: options.body ? JSON.stringify(options.body) : undefined,
};
const response = await fetch(fullUrl, fetchOptions);
if (!response.ok) {
@ -25,18 +34,30 @@ async function request<T>(method: string, url: string, options: RequestOptions =
return response.json() as Promise<T>;
}
export function get<T>(url: string, options?: Omit<RequestOptions, 'body'>): Promise<T> {
export function get<T>(
url: string,
options?: Omit<RequestOptions, 'body'>
): Promise<T> {
return request<T>('GET', url, options || {});
}
export function post<T>(url: string, options?: Omit<RequestOptions, 'body'> & { body: any }): Promise<T> {
export function post<T>(
url: string,
options?: Omit<RequestOptions, 'body'> & { body: any }
): Promise<T> {
return request<T>('POST', url, options as RequestOptions);
}
export function put<T>(url: string, options?: Omit<RequestOptions, 'body'> & { body: any }): Promise<T> {
export function put<T>(
url: string,
options?: Omit<RequestOptions, 'body'> & { body: any }
): Promise<T> {
return request<T>('PUT', url, options as RequestOptions);
}
export function del<T>(url: string, options?: Omit<RequestOptions, 'body'>): Promise<T> {
export function del<T>(
url: string,
options?: Omit<RequestOptions, 'body'>
): Promise<T> {
return request<T>('DELETE', url, options || {});
}