frontend (test): add Smart Mosaic layout helper functions and fake participant management

- Implemented helper functions for configuring Smart Mosaic layout, including setting participant count and waiting for participant visibility.
- Created a new file for managing fake participants, allowing for joining and disconnecting from LiveKit rooms using both CLI and browser-based methods.
- Introduced interfaces for browser-based fake participant options to streamline participant creation with audio and video assets.
This commit is contained in:
Carlos Santos 2025-12-02 21:02:40 +01:00
parent f930bf1447
commit 1663b008ed
24 changed files with 1841 additions and 1 deletions

View File

@ -0,0 +1,330 @@
#!/bin/bash
# =============================================================================
# Audio Generation Script for Smart Mosaic Layout Tests
# =============================================================================
# This script generates test audio files from a base audio file (base.wav)
# for testing the Smart Mosaic layout speaker detection functionality.
#
# Requirements:
# - ffmpeg 7.0+ (optimized for this version)
# - base.wav file with continuous speech audio in the same directory
#
# IMPORTANT: This script generates WAV files for best compatibility with
# Chrome's fake audio capture (--use-file-for-fake-audio-capture).
# WAV format ensures proper audio device simulation and VAD detection.
#
# Usage:
# chmod +x generate-test-audio.sh
# ./generate-test-audio.sh
# =============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BASE_AUDIO="$SCRIPT_DIR/base.wav"
OUTPUT_DIR="$SCRIPT_DIR"
# Audio settings
SAMPLE_RATE=48000
CHANNELS=1
# WAV encoding settings for Chrome fake audio capture compatibility
# PCM 16-bit is the most compatible format for Chrome's fake devices
WAV_OPTS="-c:a pcm_s16le -ar ${SAMPLE_RATE} -ac ${CHANNELS}"
# Check ffmpeg version
FFMPEG_VERSION=$(ffmpeg -version | head -n1 | grep -oP 'ffmpeg version \K[0-9]+')
echo "🔧 Detected ffmpeg major version: $FFMPEG_VERSION"
# Check if base audio exists
if [ ! -f "$BASE_AUDIO" ]; then
echo "❌ Error: base.wav not found in $SCRIPT_DIR"
echo "Please provide a base.wav file with continuous speech audio."
exit 1
fi
echo ""
echo "🎵 Generating test audio files from base.wav..."
echo " Output directory: $OUTPUT_DIR"
echo " Sample rate: ${SAMPLE_RATE}Hz, Channels: ${CHANNELS}"
echo " Codec: PCM 16-bit (WAV) for Chrome fake audio compatibility"
echo ""
# -----------------------------------------------------------------------------
# 1. continuous_speech.wav (30s)
# Continuous speech audio for participants who speak constantly
# -----------------------------------------------------------------------------
echo "1⃣ Generating continuous_speech.wav (30s of continuous speech)..."
ffmpeg -y -i "$BASE_AUDIO" -t 30 -af "aresample=${SAMPLE_RATE}" $WAV_OPTS "$OUTPUT_DIR/continuous_speech.wav" 2>/dev/null
echo " ✅ continuous_speech.wav created"
# -----------------------------------------------------------------------------
# 2. complete_silence.wav (30s)
# Complete digital silence using aevalsrc with explicit zero expression
# This generates samples with value exactly 0.0 - guaranteed no VAD trigger
# -----------------------------------------------------------------------------
echo "2⃣ Generating complete_silence.wav (30s of TRUE digital silence)..."
ffmpeg -y -f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
$WAV_OPTS "$OUTPUT_DIR/complete_silence.wav" 2>/dev/null
echo " ✅ complete_silence.wav created"
# -----------------------------------------------------------------------------
# 3. speech_5s_then_silence.wav (30s)
# 5s speech, then 25s TRUE silence
# Uses amix to combine speech with silence background for clean transitions
# -----------------------------------------------------------------------------
echo "3⃣ Generating speech_5s_then_silence.wav (5s speech + 25s TRUE silence)..."
ffmpeg -y \
-i "$BASE_AUDIO" \
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
-filter_complex "
[0:a]atrim=0:5,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE}[speech];
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
" \
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speech_5s_then_silence.wav" 2>/dev/null
echo " ✅ speech_5s_then_silence.wav created"
# -----------------------------------------------------------------------------
# 4. silence_5s_then_speech.wav (30s)
# 5s TRUE silence, then 25s speech
# -----------------------------------------------------------------------------
echo "4⃣ Generating silence_5s_then_speech.wav (5s TRUE silence + 25s speech)..."
ffmpeg -y \
-i "$BASE_AUDIO" \
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
-filter_complex "
[0:a]atrim=0:25,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=5s:all=1[speech];
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
" \
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/silence_5s_then_speech.wav" 2>/dev/null
echo " ✅ silence_5s_then_speech.wav created"
# -----------------------------------------------------------------------------
# 5. speech_gap_speech.wav (30s)
# 5s speech, 10s TRUE silence, 15s speech - for testing speaker re-activation
# -----------------------------------------------------------------------------
echo "5⃣ Generating speech_gap_speech.wav (5s speech + 10s TRUE gap + 15s speech)..."
ffmpeg -y \
-i "$BASE_AUDIO" \
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
-filter_complex "
[0:a]atrim=0:5,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE}[s1];
[0:a]atrim=5:20,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=15s:all=1[s2];
[1:a][s1][s2]amix=inputs=3:duration=first:dropout_transition=0,volume=3[out]
" \
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speech_gap_speech.wav" 2>/dev/null
echo " ✅ speech_gap_speech.wav created"
# -----------------------------------------------------------------------------
# 6-11. Sequential speaker audio files (for rotation tests)
# Each speaker has a unique time window for speech with TRUE silence elsewhere
# -----------------------------------------------------------------------------
echo "6⃣ Generating sequential speaker audio files (A through F)..."
# Speaker A: speaks 0-3s, then TRUE silence
echo " → speaker_seq_A.wav (speaks at 0-3s)"
ffmpeg -y \
-i "$BASE_AUDIO" \
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
-filter_complex "
[0:a]atrim=0:3,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE}[speech];
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
" \
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speaker_seq_A.wav" 2>/dev/null
# Speaker B: TRUE silence 0-5s, speaks 5-8s, then TRUE silence
echo " → speaker_seq_B.wav (speaks at 5-8s)"
ffmpeg -y \
-i "$BASE_AUDIO" \
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
-filter_complex "
[0:a]atrim=0:3,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=5s:all=1[speech];
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
" \
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speaker_seq_B.wav" 2>/dev/null
# Speaker C: TRUE silence 0-10s, speaks 10-13s, then TRUE silence
echo " → speaker_seq_C.wav (speaks at 10-13s)"
ffmpeg -y \
-i "$BASE_AUDIO" \
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
-filter_complex "
[0:a]atrim=0:3,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=10s:all=1[speech];
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
" \
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speaker_seq_C.wav" 2>/dev/null
# Speaker D: TRUE silence 0-15s, speaks 15-18s, then TRUE silence
echo " → speaker_seq_D.wav (speaks at 15-18s)"
ffmpeg -y \
-i "$BASE_AUDIO" \
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
-filter_complex "
[0:a]atrim=0:3,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=15s:all=1[speech];
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
" \
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speaker_seq_D.wav" 2>/dev/null
# Speaker E: TRUE silence 0-20s, speaks 20-23s, then TRUE silence
echo " → speaker_seq_E.wav (speaks at 20-23s)"
ffmpeg -y \
-i "$BASE_AUDIO" \
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
-filter_complex "
[0:a]atrim=0:3,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=20s:all=1[speech];
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
" \
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speaker_seq_E.wav" 2>/dev/null
# Speaker F: TRUE silence 0-25s, speaks 25-28s, then TRUE silence
echo " → speaker_seq_F.wav (speaks at 25-28s)"
ffmpeg -y \
-i "$BASE_AUDIO" \
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
-filter_complex "
[0:a]atrim=0:3,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=25s:all=1[speech];
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
" \
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/speaker_seq_F.wav" 2>/dev/null
echo " ✅ Sequential speaker files created (A-F)"
# -----------------------------------------------------------------------------
# 12. simultaneous_then_solo.wav (30s)
# 15s speech then 15s TRUE silence
# Used for the "simultaneous speech" test (this participant continues speaking)
# -----------------------------------------------------------------------------
echo "7⃣ Generating simultaneous_then_solo.wav (15s speech + 15s TRUE silence)..."
ffmpeg -y \
-i "$BASE_AUDIO" \
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
-filter_complex "
[0:a]atrim=0:15,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE}[speech];
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
" \
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/simultaneous_then_solo.wav" 2>/dev/null
echo " ✅ simultaneous_then_solo.wav created"
# -----------------------------------------------------------------------------
# 13. simultaneous_then_stop.wav (30s)
# 5s speech then 25s TRUE silence
# Used for participants who stop speaking after simultaneous period
# -----------------------------------------------------------------------------
echo "8⃣ Generating simultaneous_then_stop.wav (5s speech + 25s TRUE silence)..."
ffmpeg -y \
-i "$BASE_AUDIO" \
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
-filter_complex "
[0:a]atrim=0:5,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE}[speech];
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
" \
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/simultaneous_then_stop.wav" 2>/dev/null
echo " ✅ simultaneous_then_stop.wav created"
# -----------------------------------------------------------------------------
# 14. low_volume_speech.wav (30s)
# Continuous speech at 10% volume - below the audioLevel threshold (0.15)
# Used to test that participants with low audio levels are filtered out
# -----------------------------------------------------------------------------
echo "9⃣ Generating low_volume_speech.wav (30s speech at 10% volume)..."
ffmpeg -y \
-f lavfi -i "anoisesrc=color=pink:amplitude=0.02:s=${SAMPLE_RATE}:d=30" \
$WAV_OPTS "$OUTPUT_DIR/ambient_pink_noise.wav" 2>/dev/null
echo " ✅ low_volume_speech.wav created"
# -----------------------------------------------------------------------------
# 15. brief_sound_1s.wav (30s)
# Only 1 second of speech followed by silence
# Used to test minimum speaking duration filter (should be filtered out)
# -----------------------------------------------------------------------------
echo "🔟 Generating brief_sound_1s.wav (1s speech + 29s silence)..."
ffmpeg -y \
-i "$BASE_AUDIO" \
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
-filter_complex "
[0:a]atrim=0:1,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=5000|5000[speech];
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
" \
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/brief_sound_1s_at_5s.wav" 2>/dev/null
echo " ✅ brief_sound_1s_at_5s.wav created"
# -----------------------------------------------------------------------------
# 16. brief_cough.wav (30s)
# Only 0.5 seconds of sound (simulating a cough) followed by silence
# Used to test that very brief sounds are filtered out
# -----------------------------------------------------------------------------
echo "1⃣1⃣ Generating brief_cough.wav (0.5s sound + 29.5s silence)..."
ffmpeg -y \
-i "$BASE_AUDIO" \
-f lavfi -i "aevalsrc=0:c=mono:s=${SAMPLE_RATE}:d=30" \
-filter_complex "
[0:a]atrim=0:0.5,asetpts=PTS-STARTPTS,aresample=${SAMPLE_RATE},adelay=5000|5000[speech];
[1:a][speech]amix=inputs=2:duration=first:dropout_transition=0,volume=2[out]
" \
-map "[out]" -t 30 $WAV_OPTS "$OUTPUT_DIR/brief_cough_at_5s.wav" 2>/dev/null
echo " ✅ brief_cough_at_5s.wav created"
# -----------------------------------------------------------------------------
# Verify silence in generated files
# -----------------------------------------------------------------------------
echo ""
echo "🔍 Verifying silence quality in generated files..."
verify_silence() {
local file=$1
local expected_silence_start=$2
# Check RMS level in silence portion (should be exactly 0 or very close)
local rms=$(ffmpeg -i "$file" -af "atrim=${expected_silence_start}:${expected_silence_start}+1,astats=metadata=1:reset=1" -f null - 2>&1 | grep "RMS level" | head -1 | grep -oP '[-0-9.]+' | head -1)
if [ -n "$rms" ]; then
echo " $file: RMS at ${expected_silence_start}s = ${rms}dB"
fi
}
# Verify a few key files
verify_silence "$OUTPUT_DIR/complete_silence.wav" 15
verify_silence "$OUTPUT_DIR/speech_5s_then_silence.wav" 10
verify_silence "$OUTPUT_DIR/speaker_seq_B.wav" 2
# -----------------------------------------------------------------------------
# Summary
# -----------------------------------------------------------------------------
echo ""
echo "============================================================================="
echo "✅ Audio generation complete! (WAV format for Chrome fake audio capture)"
echo "============================================================================="
echo ""
echo "Generated files:"
echo " 📁 $OUTPUT_DIR/"
echo " ├── continuous_speech.wav (30s continuous speech)"
echo " ├── complete_silence.wav (30s TRUE digital silence - aevalsrc=0)"
echo " ├── speech_5s_then_silence.wav (5s speech + 25s TRUE silence)"
echo " ├── silence_5s_then_speech.wav (5s TRUE silence + 25s speech)"
echo " ├── speech_gap_speech.wav (5s speech + 10s gap + 15s speech)"
echo " ├── speaker_seq_A.wav (speaks at 0-3s)"
echo " ├── speaker_seq_B.wav (speaks at 5-8s)"
echo " ├── speaker_seq_C.wav (speaks at 10-13s)"
echo " ├── speaker_seq_D.wav (speaks at 15-18s)"
echo " ├── speaker_seq_E.wav (speaks at 20-23s)"
echo " ├── speaker_seq_F.wav (speaks at 25-28s)"
echo " ├── simultaneous_then_solo.wav (15s speech + 15s silence)"
echo " ├── simultaneous_then_stop.wav (5s speech + 25s silence)"
echo " ├── low_volume_speech.wav (30s speech at 10% volume - below threshold)"
echo " ├── brief_sound_1s.wav (1s speech + 29s silence - too short)"
echo " └── brief_cough.wav (0.5s sound + 29.5s silence - simulates cough)"
echo ""
echo "Key features of this version:"
echo " • WAV format (PCM 16-bit) for Chrome fake audio capture compatibility"
echo " • Uses aevalsrc=0 for TRUE digital silence (samples = 0.0)"
echo " • amix filter for clean speech/silence transitions"
echo " • adelay for precise speech timing"
echo " • 48kHz sample rate, mono channel"
echo ""
echo "Usage in tests:"
echo " await joinBrowserFakeParticipant(browser, roomId, 'speaker1', {"
echo " audioFile: 'continuous_speech.wav'"
echo " });"
echo ""

File diff suppressed because it is too large Load Diff

View File

@ -162,7 +162,8 @@ const getDefaultRoomConfig = (): MeetRoomConfig => ({
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
}, },
chat: { enabled: true }, chat: { enabled: true },
virtualBackground: { enabled: true } virtualBackground: { enabled: true },
e2ee: { enabled: false }
}); });
// Helper function to create a room for testing // Helper function to create a room for testing
@ -616,3 +617,296 @@ export const isShareLinkOverlayyHidden = async (page: Page, overlaySelector: str
console.log('❌ Overlay is still visible'); console.log('❌ Overlay is still visible');
return false; return false;
}; };
// ==========================================
// SMART MOSAIC LAYOUT HELPER FUNCTIONS
// ==========================================
/**
* Opens the settings panel and configures the Smart Mosaic layout.
* This function:
* 1. Opens the more options menu
* 2. Clicks on the layout settings button
* 3. Selects the specified layout mode
* 4. If Smart Mosaic is selected, sets the max participants count
* 5. Closes the settings panel
*
* @param page - Playwright page object
* @param layoutMode - The layout mode to select ('mosaic' or 'smart-mosaic')
* @param maxParticipants - Maximum number of remote participants to show (1-6, only used for smart-mosaic)
*/
export const configureLayoutMode = async (
page: Page,
layoutMode: 'smart-mosaic' | 'mosaic',
maxParticipants?: number
): Promise<void> => {
console.log(
`🎛️ Configuring layout mode: ${layoutMode}${maxParticipants ? `, max participants: ${maxParticipants}` : ''}`
);
// Open more options menu
await openMoreOptionsMenu(page);
// Click on layout settings button (could be grid-layout-settings-btn or toolbar-settings-btn)
const layoutSettingsBtn = '#grid-layout-settings-btn, #toolbar-settings-btn';
await waitForElementInIframe(page, layoutSettingsBtn, { state: 'visible' });
await interactWithElementInIframe(page, layoutSettingsBtn, { action: 'click' });
// Wait for settings panel to open
await waitForElementInIframe(page, 'ov-settings-panel', { state: 'visible' });
await page.waitForTimeout(500); // Wait for panel animation
// Select the layout mode using radio buttons
const radioSelector = layoutMode === 'smart-mosaic' ? '#layout-smart-mosaic' : '#layout-mosaic';
await waitForElementInIframe(page, radioSelector, { state: 'visible' });
await interactWithElementInIframe(page, radioSelector, { action: 'click' });
await page.waitForTimeout(300); // Wait for mode change
// If Smart Mosaic is selected and maxParticipants is specified, set the slider value
if (layoutMode === 'smart-mosaic' && maxParticipants !== undefined) {
await setSmartMosaicParticipantCount(page, maxParticipants);
}
// Close the settings panel
await closeSettingsPanel(page);
console.log(
`✅ Layout configured: ${layoutMode}${maxParticipants ? ` with ${maxParticipants} max participants` : ''}`
);
};
/**
* Sets the participant count for Smart Mosaic layout using the slider.
* This function should be called when the settings panel is already open
* and Smart Mosaic mode is selected.
*
* @param page - Playwright page object
* @param count - Number of participants to show (1-6)
*/
export const setSmartMosaicParticipantCount = async (page: Page, count: number): Promise<void> => {
if (count < 1 || count > 6) {
throw new Error(`Invalid participant count: ${count}. Must be between 1 and 6.`);
}
console.log(`🔢 Setting Smart Mosaic participant count to: ${count}`);
// Wait for the slider to be visible (only appears in Smart Mosaic mode)
const sliderSelector = '.participant-slider input[matSliderThumb]';
await waitForElementInIframe(page, sliderSelector, { state: 'visible', timeout: 5000 });
// Get the slider element and set its value
const frameLocator = await getIframeInShadowDom(page);
const slider = frameLocator.locator(sliderSelector);
// Use keyboard to set the value - first focus, then set value via fill
await slider.focus();
// Clear and set the value - the slider input accepts direct value assignment
await slider.fill(count.toString());
await page.waitForTimeout(300); // Wait for value to be applied
console.log(`✅ Smart Mosaic participant count set to: ${count}`);
};
/**
* Closes the settings panel by clicking the close button or clicking outside
*
* @param page - Playwright page object
*/
export const closeSettingsPanel = async (page: Page): Promise<void> => {
// Try to close via the panel close button
const closeButtonSelector = '.panel-close-button';
try {
await waitForElementInIframe(page, closeButtonSelector, { state: 'visible', timeout: 2000 });
await interactWithElementInIframe(page, closeButtonSelector, { action: 'click' });
} catch {
// If close button not found, click outside the panel
await interactWithElementInIframe(page, 'body', { action: 'click' });
}
await page.waitForTimeout(500); // Wait for panel to close
};
export const muteAudio = async (page: Page) => {
await interactWithElementInIframe(page, '#mic-btn', { action: 'click' });
await page.waitForTimeout(500); // Wait for action to complete
};
/**
* Gets the number of visible participant tiles in the video grid.
* This counts all participant containers currently displayed.
*
* @param page - Playwright page object
* @returns Number of visible participant tiles
*/
export const getVisibleParticipantsCount = async (page: Page): Promise<number> => {
const participantSelector = '.OV_publisher';
const count = await countElementsInIframe(page, participantSelector);
console.log(`👥 Visible participants in grid: ${count}`);
return count;
};
/**
* Gets the identities of all visible participants in the grid.
*
* @param page - Playwright page object
* @returns Array of participant names/identities visible in the grid
*/
export const getVisibleParticipantNames = async (page: Page): Promise<string[]> => {
const frameLocator = await getIframeInShadowDom(page);
const participantContainers = frameLocator.locator('.participant-name-container');
const count = await participantContainers.count();
const names: string[] = [];
for (let i = 0; i < count; i++) {
const container = participantContainers.nth(i);
const participantName = await container.textContent();
if (participantName) {
names.push(participantName.trim());
}
}
console.log(`👥 Visible participant names: ${names.join(', ')}`);
return names;
};
/**
* Waits for the participant grid to show a specific number of participants.
*
* @param page - Playwright page object
* @param expectedCount - Expected number of visible participants
* @param timeout - Maximum time to wait in milliseconds (default: 10000)
* @returns true if the expected count is reached, false if timeout
*/
export const waitForParticipantCount = async (
page: Page,
expectedCount: number,
timeout: number = 10000
): Promise<boolean> => {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const currentCount = await getVisibleParticipantsCount(page);
if (currentCount === expectedCount) {
console.log(`✅ Participant count reached: ${expectedCount}`);
return true;
}
await page.waitForTimeout(500);
}
const finalCount = await getVisibleParticipantsCount(page);
console.log(`❌ Timeout waiting for participant count. Expected: ${expectedCount}, Got: ${finalCount}`);
return false;
};
/**
* Waits for a specific participant to become visible in the grid.
* Uses polling to check if the participant's name appears in the visible participants list.
*
* @param page - Playwright page object
* @param participantName - The name/identity of the participant to wait for
* @param timeout - Maximum time to wait in milliseconds (default: 30000)
* @returns true if the participant becomes visible, throws error if timeout
*/
export const waitForParticipantVisible = async (
page: Page,
participantName: string,
timeout: number = 30000
): Promise<boolean> => {
console.log(`⏳ Waiting for participant "${participantName}" to become visible...`);
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const visibleNames = await getVisibleParticipantNames(page);
if (visibleNames.includes(participantName)) {
console.log(`✅ Participant "${participantName}" is now visible`);
return true;
}
await page.waitForTimeout(500);
}
const finalNames = await getVisibleParticipantNames(page);
throw new Error(
`Timeout waiting for participant "${participantName}" to become visible. ` +
`Current visible: [${finalNames.join(', ')}]`
);
};
/**
* Waits for a specific participant to become hidden (not visible) in the grid.
* Uses polling to check if the participant's name disappears from the visible participants list.
*
* @param page - Playwright page object
* @param participantName - The name/identity of the participant to wait for hiding
* @param timeout - Maximum time to wait in milliseconds (default: 30000)
* @returns true if the participant becomes hidden, throws error if timeout
*/
export const waitForParticipantHidden = async (
page: Page,
participantName: string,
timeout: number = 30000
): Promise<boolean> => {
console.log(`⏳ Waiting for participant "${participantName}" to become hidden...`);
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const visibleNames = await getVisibleParticipantNames(page);
if (!visibleNames.includes(participantName)) {
console.log(`✅ Participant "${participantName}" is now hidden`);
return true;
}
await page.waitForTimeout(500);
}
const finalNames = await getVisibleParticipantNames(page);
throw new Error(
`Timeout waiting for participant "${participantName}" to become hidden. ` +
`Current visible: [${finalNames.join(', ')}]`
);
};
/**
* Waits for a layout change where one participant replaces another.
* Useful for testing Smart Mosaic speaker rotation.
*
* @param page - Playwright page object
* @param participantToAppear - The participant that should become visible
* @param participantToDisappear - The participant that should become hidden
* @param timeout - Maximum time to wait in milliseconds (default: 30000)
* @returns true if the swap happens, throws error if timeout
*/
export const waitForParticipantSwap = async (
page: Page,
participantToAppear: string,
participantToDisappear: string,
timeout: number = 30000
): Promise<boolean> => {
console.log(`⏳ Waiting for swap: "${participantToAppear}" replaces "${participantToDisappear}"...`);
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const visibleNames = await getVisibleParticipantNames(page);
const newIsVisible = visibleNames.includes(participantToAppear);
const oldIsHidden = !visibleNames.includes(participantToDisappear);
if (newIsVisible && oldIsHidden) {
console.log(`✅ Swap complete: "${participantToAppear}" replaced "${participantToDisappear}"`);
return true;
}
await page.waitForTimeout(500);
}
const finalNames = await getVisibleParticipantNames(page);
throw new Error(
`Timeout waiting for participant swap. Expected "${participantToAppear}" to replace "${participantToDisappear}". ` +
`Current visible: [${finalNames.join(', ')}]`
);
};
/**
* Helper function to sleep for a specified time
*/
export const sleep = (ms: number): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, ms));
};

View File

@ -0,0 +1,283 @@
import { BrowserContext, chromium, Page } from '@playwright/test';
import { BrowserFakeParticipantOptions } from '../interfaces/fake-participant';
import { ChildProcess, spawn } from 'child_process';
import { fileURLToPath } from 'url';
import * as fs from 'fs';
import * as path from 'path';
import { joinRoomAs, leaveRoom, prepareForJoiningRoom, sleep, waitForElementInIframe } from './function-helpers';
import { MEET_TESTAPP_URL } from '../config';
// LiveKit credentials
const LIVEKIT_API_KEY = process.env['LIVEKIT_API_KEY'] || 'devkey';
const LIVEKIT_API_SECRET = process.env['LIVEKIT_API_SECRET'] || 'secret';
// Store fake participant processes for cleanup
const fakeParticipantProcesses = new Map<string, ChildProcess>();
// ES Module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ==========================================
// FAKE PARTICIPANT HELPER FUNCTIONS
// ==========================================
/**
* Path to the test audio assets directory
* Uses __dirname to resolve relative to this file's location
*/
const AUDIO_ASSETS_DIR = path.resolve(__dirname, '../assets/audio');
/**
* Joins a fake participant to a LiveKit room using the lk CLI.
* This participant can publish audio to trigger speaker detection.
*
* @param roomId - The room ID to join
* @param identity - The participant identity/name
* @param options - Options for publishing media
*/
export const joinFakeParticipant = async (roomId: string, identity: string): Promise<void> => {
console.log(`🤖 Joining fake participant: ${identity} to room: ${roomId}`);
const process = spawn('lk', [
'room',
'join',
roomId,
'--identity',
identity,
'--publish-demo',
'--api-key',
LIVEKIT_API_KEY,
'--api-secret',
LIVEKIT_API_SECRET
]);
// Store process for cleanup
fakeParticipantProcesses.set(`${roomId}-${identity}`, process);
// Wait for participant to join
await sleep(1500);
console.log(`✅ Fake participant joined: ${identity}`);
};
/**
* Disconnects a specific fake participant from the room.
*
* @param roomId - The room ID
* @param identity - The participant identity to disconnect
*/
export const disconnectFakeParticipant = async (roomId: string, identity: string): Promise<void> => {
const key = `${roomId}-${identity}`;
const process = fakeParticipantProcesses.get(key);
if (process) {
process.kill();
fakeParticipantProcesses.delete(key);
console.log(`👋 Disconnected fake participant: ${identity}`);
await sleep(500);
}
};
/**
* Disconnects all fake participants from all rooms.
* Should be called in afterEach or afterAll hooks.
*/
export const disconnectAllFakeParticipants = async (): Promise<void> => {
for (const [key, process] of fakeParticipantProcesses) {
process.kill();
}
fakeParticipantProcesses.clear();
await sleep(500);
};
// ==========================================
// BROWSER-BASED FAKE PARTICIPANT HELPERS
// ==========================================
// These functions use Playwright browser tabs with fake audio devices
// to create participants that properly trigger LiveKit's VAD
/**
* Store for browser-based fake participant contexts
* Each participant gets its own browser context with specific Chrome args
*/
const browserFakeParticipants = new Map<string, { context: BrowserContext; page: Page }>();
/**
* Joins a fake participant to a room using a new browser instance with fake audio device.
* This method properly triggers LiveKit's Voice Activity Detection (VAD) because
* it uses Chrome's --use-file-for-fake-audio-capture flag.
*
* IMPORTANT: The audio file should be in WAV format for best compatibility with Chrome.
* Chrome's fake audio capture works best with uncompressed audio.
*
* @param roomId - The room ID to join
* @param identity - The participant identity/name
* @param options - Options for the fake participant
* @returns The page object for the fake participant (for further interactions)
*
* @example
* ```typescript
* const participantPage = await joinBrowserFakeParticipant(
* browser,
* roomId,
* 'RemoteA-Speaker',
* { audioFile: 'continuous_speech.wav' }
* );
* ```
*/
export const joinBrowserFakeParticipant = async (
roomId: string,
identity: string,
options: BrowserFakeParticipantOptions = {}
): Promise<Page> => {
console.log(`🌐 Joining browser-based fake participant: ${identity} to room: ${roomId}`);
const { audioFile, videoFile, displayName = identity, enableVideo = true, enableAudio = true } = options;
// Video assets directory (sibling to audio assets)
const VIDEO_ASSETS_DIR = path.resolve(path.dirname(AUDIO_ASSETS_DIR), 'video');
// Resolve audio file path
let audioFilePath: string | undefined;
if (audioFile) {
audioFilePath = path.isAbsolute(audioFile) ? audioFile : path.resolve(AUDIO_ASSETS_DIR, audioFile);
if (!fs.existsSync(audioFilePath)) {
throw new Error(`Audio file not found: ${audioFilePath}`);
}
console.log(` 🎵 Using audio file: ${audioFilePath}`);
}
// Resolve video file path
let videoFilePath: string | undefined;
if (videoFile) {
videoFilePath = path.isAbsolute(videoFile) ? videoFile : path.resolve(VIDEO_ASSETS_DIR, videoFile);
if (!fs.existsSync(videoFilePath)) {
throw new Error(`Video file not found: ${videoFilePath}`);
}
console.log(` 🎬 Using video file: ${videoFilePath}`);
}
// Chrome flags for fake media devices
const chromeArgs = [
'--use-fake-ui-for-media-stream', // Auto-accept media permissions
'--use-fake-device-for-media-stream', // Use fake devices
'--allow-file-access-from-files',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage'
];
// Add fake audio capture file if specified
if (audioFilePath) {
chromeArgs.push(`--use-file-for-fake-audio-capture=${audioFilePath}`);
}
// Add fake video capture file if specified
// Chrome supports Y4M (YUV4MPEG2) and MJPEG formats for fake video capture
if (videoFilePath) {
// chromeArgs.push(`--use-file-for-fake-video-capture=${videoFilePath}`);
}
console.log(` 🔧 Chrome args: ${chromeArgs.join(' ')}`);
// Launch a new browser context with the specific Chrome args
// We need to use launchPersistentContext to pass Chrome args
const userDataDir = `/tmp/playwright-fake-participant-${identity}-${Date.now()}`;
const context = await chromium.launchPersistentContext(userDataDir, {
headless: true, // Set to false for debugging
args: chromeArgs,
ignoreHTTPSErrors: true,
bypassCSP: true
});
// Get the first page or create one
const page = context.pages()[0] || (await context.newPage());
// Store for cleanup
const key = `${roomId}-${identity}`;
browserFakeParticipants.set(key, { context, page });
// Handle the lobby/prejoin if present - click join button
try {
await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId);
await joinRoomAs('speaker', identity, page);
await waitForElementInIframe(page, 'ov-session', { state: 'visible' });
await waitForElementInIframe(page, '.OV_publisher', { state: 'visible', timeout: 10000 });
} catch (e) {
console.log(` ⚠️ No lobby found or already in room for ${identity}: ${e}`);
}
return page;
};
/**
* Disconnects a browser-based fake participant from the room.
*
* @param roomId - The room ID
* @param identity - The participant identity to disconnect
*/
export const disconnectBrowserFakeParticipant = async (roomId: string, identity: string): Promise<void> => {
const key = `${roomId}-${identity}`;
const participant = browserFakeParticipants.get(key);
if (participant) {
try {
await leaveRoom(participant.page);
await participant.page.close();
} catch (e) {
/* ignore */
}
try {
await participant.context.close();
} catch (e) {
/* ignore */
}
browserFakeParticipants.delete(key);
console.log(`👋 Disconnected browser fake participant: ${identity}`);
}
};
/**
* Disconnects all browser-based fake participants.
* Should be called in afterEach or afterAll hooks.
*/
export const disconnectAllBrowserFakeParticipants = async (): Promise<void> => {
const keys = Array.from(browserFakeParticipants.keys());
for (const key of keys) {
const participant = browserFakeParticipants.get(key);
if (participant) {
try {
await participant.page.close();
} catch (e) {
/* ignore */
}
try {
await participant.context.close();
} catch (e) {
/* ignore */
}
}
}
browserFakeParticipants.clear();
if (keys.length > 0) {
console.log(`👋 Disconnected all browser fake participants (${keys.length})`);
}
};
/**
* Gets the page object for a browser-based fake participant.
* Useful for interacting with the participant's UI (mute/unmute, etc.)
*
* @param roomId - The room ID
* @param identity - The participant identity
* @returns The Page object or undefined if not found
*/
export const getBrowserFakeParticipantPage = (roomId: string, identity: string): Page | undefined => {
const key = `${roomId}-${identity}`;
return browserFakeParticipants.get(key)?.page;
};

View File

@ -0,0 +1,15 @@
/**
* Options for joining a browser-based fake participant
*/
export interface BrowserFakeParticipantOptions {
/** Path to audio file (relative to assets/audio or absolute) - WAV format recommended */
audioFile?: string;
/** Path to video file (relative to assets/video or absolute) - Y4M or MJPEG format recommended for Chrome */
videoFile?: string;
/** Participant display name */
displayName?: string;
/** Whether to enable video (default: true) */
enableVideo?: boolean;
/** Whether to enable audio (default: true) */
enableAudio?: boolean;
}