"""Offline dedicated voice shell tests using the real start/stop code and fake SDK.""" from playwright.sync_api import sync_playwright, expect from test_chat_web import fixture from test_voice_settings_ui import VOICE_SDK def transport(page, token_failure=False, connecting=False): page.route('https://hope.test/livekit-client.umd.js', lambda r: r.fulfill( body=VOICE_SDK.replace("this.state='connected';const agent", "if(window.holdConnect) await new Promise(resolve=>window.releaseConnect=resolve);this.state='connected';const agent"), content_type='application/javascript')) def token(r): page.evaluate('(body) => window.lastTokenBody=body', r.request.post_data_json) if token_failure: r.fulfill(status=503, json={'error': 'Voice unavailable'}) else: r.fulfill(json={'token': 'offline-token', 'conversation_id': r.request.post_data_json.get('conversation_id')}) page.route('https://hope.test/token', token) if connecting: page.add_init_script('window.holdConnect=true') def open_thread(page, width): page.goto('https://hope.test/'); page.wait_for_load_state('networkidle') if width <= 760: page.locator('#drawerOpen').click() page.get_by_role('button', name='A saved thought', exact=True).click() expect(page.locator('#chatTimeline')).to_contain_text('Original question') def packet(page, text): page.evaluate("""text => testRoom.emit('DataReceived',new TextEncoder().encode(JSON.stringify({type:'transcript',role:'agent',text})),testRoom.remoteParticipants.get('agent'),null,'transcript')""", text) def test_voice_shell_real_start_mute_scroll_stop_and_saved_history(): with sync_playwright() as p: browser = p.chromium.launch(headless=True) for width, height in [(1440, 900), (390, 844), (320, 568), (844, 390)]: page = browser.new_page(viewport={'width': width, 'height': height}, reduced_motion='reduce') errors = []; page.on('pageerror', lambda e: errors.append(str(e))) calls, _, conversations, messages = fixture(page); transport(page) page.add_init_script("localStorage.setItem('hope_history:https://hope.test:alice',JSON.stringify([{role:'agent',text:'Unrelated account snapshot'}]))") open_thread(page, width) page.locator('#primaryInput').fill('Keep my draft') page.locator('#talkToHope').click() page.wait_for_function('HopeVoice.state().ready') expect(page.locator('#legacyVoice')).to_be_visible() for selector in ['#primaryComposer', '#chatTimeline', '.chat-footnote']: expect(page.locator(selector)).to_be_hidden() expect(page.locator('#primaryInput')).to_be_disabled() expect(page.locator('#voiceHistory')).to_contain_text('Original question') assert 'Unrelated account snapshot' not in page.locator('#conversation').inner_text() page.locator('#muteBtn').click() expect(page.locator('#muteBtn')).to_have_text('Unmute') expect(page.locator('#muteBtn')).to_have_attribute('aria-pressed', 'true') page.locator('#muteBtn').click() for n in range(12): packet(page, f'Live response {n} ' + 'Readable transcript. ' * 30) packet(page, '') assert page.locator('#messages img').count() == 0 assert page.locator('#voiceHistory').get_by_text('Original question', exact=True).count() == 1 assert page.evaluate("""() => {const s=document.querySelector('#conversation');return s.scrollHeight>s.clientHeight && s.scrollTop>0;}""") page.locator('#conversation').evaluate('(el) => el.scrollTop=0') packet(page, 'Do not jump while reading earlier messages') assert page.locator('#conversation').evaluate('(el) => el.scrollTop') == 0 for control in ['#muteBtn', '#stopBtn', '#settingsBtn']: box = page.locator(control).bounding_box() assert box and box['y'] >= 0 and box['y'] + box['height'] <= height assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') page.locator('#settingsBtn').click(); expect(page.locator('#settingsDialog')).to_be_visible() page.locator('#settingsClose').click() messages['c1'].append(dict(id='v1', role='assistant', content='Actually saved voice reply', status='completed')) page.locator('#stopBtn').click() expect(page.locator('#legacyVoice')).to_be_hidden() expect(page.locator('#chatTimeline')).to_contain_text('Actually saved voice reply') expect(page.locator('#primaryInput')).to_have_value('Keep my draft') expect(page.locator('#primaryInput')).to_be_enabled() expect(page.locator('#talkToHope')).to_be_focused() assert page.evaluate('window.lastTokenBody') == {'conversation_id': 'c1'} assert not errors page.close() browser.close() def test_compatibility_text_composer_uses_http_and_preserves_primary_draft(): with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(); calls, _, _, _ = fixture(page); transport(page) open_thread(page, 1280) page.locator('#primaryInput').fill('Existing durable draft') page.evaluate("document.getElementById('chatInput').value='Legacy text';document.getElementById('chatComposer').requestSubmit()") expect(page.locator('#primaryInput')).to_have_value('Existing durable draft') assert not any(path.endswith('/messages') or path == '/token' for _, path, _ in calls) page.locator('#primaryInput').fill('') page.evaluate("document.getElementById('chatComposer').requestSubmit()") expect(page.locator('#chatTimeline')).to_contain_text('Legacy text') assert any(path.endswith('/messages') and body['content'] == 'Legacy text' for _, path, body in calls) assert page.evaluate('window.lastTokenBody') is None assert page.evaluate('window.testRoom') is None browser.close() def test_voice_failure_cancel_disconnect_and_logout_restore_text(): with sync_playwright() as p: browser = p.chromium.launch(headless=True) for mode in ['token-failure', 'mic-failure', 'cancel', 'disconnect', 'logout']: page = browser.new_page(viewport={'width': 390, 'height': 844}) fixture(page); transport(page, token_failure=mode == 'token-failure', connecting=mode == 'cancel') if mode == 'mic-failure': page.add_init_script('window.denyMic=true') open_thread(page, 390); page.locator('#primaryInput').fill('Draft survives') page.locator('#talkToHope').click() if mode == 'cancel': expect(page.locator('#stopBtn')).to_be_visible() page.locator('#stopBtn').click() page.evaluate('window.releaseConnect?.()') elif mode not in ['token-failure', 'mic-failure']: page.wait_for_function('HopeVoice.state().ready') if mode == 'disconnect': page.evaluate("testRoom.emit('Disconnected')") else: page.locator('#drawerOpen').click(); page.locator('#accountMenuBtn').click(); page.locator('#logoutBtn').click() expect(page.locator('#legacyVoice')).to_be_hidden() page.wait_for_function('!HopeVoice.state().active && !HopeVoice.state().microphone_enabled') if mode == 'logout': expect(page.locator('#loginGate')).to_be_visible() expect(page.locator('#primaryInput')).to_have_value('') expect(page.locator('#voiceHistory')).to_be_empty() else: expect(page.locator('#primaryComposer')).to_be_visible() expect(page.locator('#primaryInput')).to_be_enabled() expect(page.locator('#primaryInput')).to_have_value('Draft survives') if mode.endswith('failure'): expect(page.locator('#chatNotice')).to_contain_text('Could not start voice') else: expect(page.locator('#chatNotice')).not_to_contain_text('saved') page.close() browser.close()