135 lines
8.4 KiB
Python
135 lines
8.4 KiB
Python
"""Offline browser coverage of the English voice picker using local assets."""
|
|
from pathlib import Path
|
|
from playwright.sync_api import sync_playwright, expect
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def test_english_voice_search_selection_and_retry():
|
|
available = True
|
|
account = {'id': 'alice', 'username': 'alice', 'display_name': 'Alice', 'role': 'user', 'status': 'ready'}
|
|
voices = [
|
|
{'id': 'en-US-Ava:DragonHDOmniLatestNeural', 'name': 'Ava', 'locale': 'en-US', 'gender': 'Female', 'tag': 'Omni'},
|
|
{'id': 'en-GB-Sonia:DragonHDOmniLatestNeural', 'name': 'Sonia', 'locale': 'en-GB', 'gender': 'Female', 'tag': 'Omni'},
|
|
{'id': 'en-AU-WilliamNeural', 'name': 'William', 'locale': 'en-AU', 'gender': 'Male', 'tag': 'Neural'},
|
|
]
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True)
|
|
page = browser.new_page(viewport={'width': 390, 'height': 844})
|
|
errors = []
|
|
page.on('pageerror', lambda e: errors.append(str(e)))
|
|
def route(req):
|
|
url = req.request.url
|
|
if '/api/auth/me' in url:
|
|
req.fulfill(json={'user': account})
|
|
elif url.endswith('/voices'):
|
|
if available:
|
|
req.fulfill(json={'voices': voices, 'default_voice': voices[0]['id'], 'catalog_available': True})
|
|
else:
|
|
req.fulfill(status=503, json={'error': 'Unavailable'})
|
|
elif '/api/settings/model' in url:
|
|
req.fulfill(json={'settings': {'model': 'auto', 'thinking_level': 'none'}, 'models': [], 'catalog_available': False})
|
|
else:
|
|
asset = ROOT / 'web' / (url.split('/', 3)[-1].split('?')[0] or 'index.html')
|
|
types = {'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.svg': 'image/svg+xml'}
|
|
if asset.is_file():
|
|
req.fulfill(body=asset.read_bytes(), content_type=types.get(asset.suffix, 'application/octet-stream'))
|
|
else:
|
|
req.fulfill(status=404, body='')
|
|
page.route('https://hope.test/**', route)
|
|
page.add_init_script("localStorage.setItem('preferred_voice', 'en-US-Ava:DragonHDLatestNeural')")
|
|
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
|
page.locator('#drawerOpen').click()
|
|
page.locator('#accountMenuBtn').click()
|
|
page.locator('[data-settings-target="preferences"]').click()
|
|
expect(page.locator('#voiceList [aria-checked=true]')).to_contain_text('Ava')
|
|
expect(page.locator('#voiceCatalogStatus')).to_contain_text('3 English voices')
|
|
page.locator('#voiceSearch').fill('en-GB')
|
|
expect(page.locator('#voiceList [role=radio]')).to_have_count(1)
|
|
page.locator('#voiceList [role=radio]').press('Enter')
|
|
assert page.evaluate("localStorage.getItem('preferred_voice')") == voices[1]['id']
|
|
page.locator('#voiceSearch').fill('no-matching-voice')
|
|
expect(page.locator('#voiceList')).to_contain_text('No matching voices')
|
|
page.locator('#voiceSearch').fill('')
|
|
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
|
page.screenshot(path='/tmp/hope-english-voices-mobile.png', full_page=True)
|
|
page.locator('#settingsClose').click()
|
|
available = False
|
|
page.evaluate("HopeSettings.open('preferences')")
|
|
expect(page.locator('#retryVoices')).to_be_visible()
|
|
expect(page.locator('#voiceList [role=radio]')).to_have_count(3)
|
|
available = True
|
|
page.locator('#retryVoices').click()
|
|
expect(page.locator('#retryVoices')).to_be_hidden()
|
|
assert errors == []
|
|
browser.close()
|
|
|
|
|
|
# Synthetic transport tests browser wiring only, not actual WebRTC/audio quality.
|
|
VOICE_SDK = r"""
|
|
(() => {
|
|
const events=['ParticipantConnected','ParticipantDisconnected','TrackSubscribed','TrackUnsubscribed','AudioPlaybackStatusChanged','DataReceived','ConnectionQualityChanged','Disconnected','TrackMuted','TrackUnmuted','LocalTrackUnpublished'];
|
|
class Room {
|
|
constructor(){this.handlers={};this.state='disconnected';this.remoteParticipants=new Map();this.localParticipant={isMicrophoneEnabled:false,getTrackPublication:()=>null,publishData:async()=>{},setMicrophoneEnabled:async enabled=>{if(enabled && window.denyMic)throw Error('Permission denied');this.localParticipant.isMicrophoneEnabled=enabled;}};window.testRoom=this;}
|
|
on(name,fn){(this.handlers[name]||=[]).push(fn);return this;}
|
|
emit(name,...args){(this.handlers[name]||[]).forEach(fn=>fn(...args));}
|
|
registerTextStreamHandler(){} async startAudio(){}
|
|
async disconnect(){this.state='disconnected';this.localParticipant.isMicrophoneEnabled=false;this.emit('Disconnected');}
|
|
async connect(){this.state='connected';const agent={identity:'agent',isAgent:true};this.remoteParticipants.set('agent',agent);setTimeout(()=>this.emit('DataReceived',new TextEncoder().encode(JSON.stringify({type:'agent_ready'})),agent,null,'conversation-control'),20);}
|
|
}
|
|
window.LivekitClient={Room,RoomEvent:Object.fromEntries(events.map(x=>[x,x])),Track:{Source:{Microphone:'microphone'},Kind:{Audio:'audio'}}};
|
|
})();
|
|
"""
|
|
|
|
|
|
def test_voice_api_lifecycle_and_denied_permission():
|
|
account = {'id': 'alice', 'username': 'alice', 'display_name': 'Alice', 'role': 'user', 'status': 'ready'}
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True)
|
|
page = browser.new_page()
|
|
errors = []
|
|
page.on('pageerror', lambda error: errors.append(str(error)))
|
|
def route(req):
|
|
url = req.request.url
|
|
if url.endswith('/livekit-client.umd.js'):
|
|
req.fulfill(body=VOICE_SDK, content_type='application/javascript')
|
|
elif '/api/auth/me' in url:
|
|
req.fulfill(json={'user': account})
|
|
elif url.endswith('/token'):
|
|
req.fulfill(json={'token': 'synthetic-token', 'conversation_id': 'voice-cid'})
|
|
elif url.endswith('/voices'):
|
|
req.fulfill(json={'voices': [], 'catalog_available': False})
|
|
elif '/api/settings/model' in url:
|
|
req.fulfill(json={'settings': {'model': 'auto', 'thinking_level': 'none'}, 'models': [], 'catalog_available': False})
|
|
elif '/api/' in url:
|
|
req.fulfill(json={'conversations': [], 'messages': [], 'files': [], 'data': []})
|
|
else:
|
|
asset = ROOT / 'web' / (url.split('/', 3)[-1].split('?')[0] or 'index.html')
|
|
types = {'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.svg': 'image/svg+xml'}
|
|
if asset.is_file():
|
|
req.fulfill(body=asset.read_bytes(), content_type=types.get(asset.suffix, 'application/octet-stream'))
|
|
else:
|
|
req.fulfill(status=404)
|
|
page.route('https://hope.test/**', route)
|
|
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
|
|
page.evaluate("window.voiceEvents=[];window.boundEvents=[];addEventListener('hope:voice-state',e=>voiceEvents.push(e.detail));addEventListener('hope:voice-conversation-bound',e=>boundEvents.push(e.detail));")
|
|
assert page.evaluate('HopeVoice.state().ready') is False
|
|
page.evaluate('HopeVoice.start()')
|
|
page.wait_for_function('HopeVoice.state().ready')
|
|
state = page.evaluate('HopeVoice.state()')
|
|
assert state['connected'] and state['agent_connected'] and state['microphone_enabled']
|
|
assert state['conversation_id'] == 'voice-cid'
|
|
assert page.evaluate('boundEvents[0].conversation_id') == 'voice-cid'
|
|
page.evaluate('HopeVoice.mute()')
|
|
assert page.evaluate('HopeVoice.state().muted && !HopeVoice.state().microphone_enabled && !HopeVoice.state().ready')
|
|
page.evaluate('HopeVoice.mute()')
|
|
assert page.evaluate('HopeVoice.state().ready')
|
|
page.evaluate("dispatchEvent(new CustomEvent('hope:conversation-selected',{detail:{conversation_id:'other-cid'}}))")
|
|
assert page.evaluate('!HopeVoice.state().active && !HopeVoice.state().connected')
|
|
assert page.evaluate('voiceEvents.at(-1).conversation_id') == 'voice-cid'
|
|
page.evaluate('window.denyMic=true;HopeVoice.start().catch(()=>{})')
|
|
page.wait_for_function("document.getElementById('status').textContent.includes('Permission denied')")
|
|
assert page.evaluate('!HopeVoice.state().active && !HopeVoice.state().ready && !HopeVoice.state().microphone_enabled')
|
|
assert errors == []
|
|
browser.close()
|