Files
hope-voice-api/tests/unit/test_chat_web.py
T

256 lines
18 KiB
Python

"""Offline Chromium tests of real Hope assets; no NAS or live provider."""
from pathlib import Path
from urllib.parse import urlparse, parse_qs
from playwright.sync_api import sync_playwright, expect
ROOT = Path(__file__).resolve().parents[2]
def fixture(page, replay=False):
calls, streams = [], []
conversation = dict(id='c1', title='A saved thought', workspace_id=None, pinned=False, revision=1, archived=False)
original = dict(id='m1', role='user', content='Original question', status='completed')
conversations, messages = {'c1': conversation}, {'c1': [original]}
preferences = dict(palette='opal', appearance='light', revision=1)
def route(r):
path, method = urlparse(r.request.url).path, r.request.method
body = r.request.post_data_json if r.request.post_data else {}
calls.append((method, path, body))
if path == '/api/auth/me': data = {'user': dict(id='alice', username='alice', role='user', status='ready')}
elif path == '/voices': data = {'voices': []}
elif path == '/api/research': data = {'available':True,'mode':'hermes_tools','reason':''}
elif path.endswith('/export'):
r.fulfill(body='private export',content_type='text/plain'); return
elif path == '/api/capabilities': data = {'version':1,'features':{}}
elif path == '/api/preferences':
if method == 'PUT': preferences.update(body); preferences['revision'] += 1
data = {'preferences': preferences}
elif path == '/api/workspaces': data = {'workspaces': [dict(id='w1',name='Writing',revision=1,instructions='Keep it concise')]}
elif path == '/api/sync': data = {'changes':[{'kind':'conversation','id':'c1','data':conversations['c1']},{'kind':'preferences','data':preferences}], 'cursor':1}
elif path == '/api/conversations':
if method == 'POST':
conversations['c2'] = dict(conversation, id='c2', title=body['title']); messages['c2'] = []
data = {'conversation': conversations['c2']}
else: data = {'conversations': [c for c in conversations.values() if c['archived'] == (parse_qs(urlparse(r.request.url).query).get('archived',['false'])[0] == 'true')], 'next_cursor': None}
elif path.endswith('/events'):
streams.append(r.request.url)
message = 'id: 1\nevent: message\ndata: {"message":{"id":"a1","role":"assistant","content":"## Answer\\n| Name | Value |\\n| --- | --- |\\n| <img src=x> | **safe** |\\n**Safe** and `clear`.\\n<script>window.injected=true</script>\\n[bad](javascript:alert(1))","status":"completed"}}\n\n'
done = 'id: 2\nevent: done\ndata: {"run":{"id":"r1","status":"completed"}}\n\n'
if not replay or len(streams) > 1:
for c in conversations.values(): c['revision'] += 1
r.fulfill(body=message if replay and len(streams) == 1 else message + done, content_type='text/event-stream'); return
elif path.endswith('/messages'):
cid = path.split('/')[3]; message = dict(id='u2', role='user', content=body['content'], status='completed'); messages[cid].append(message)
data = {'message':message,'run':{'id':'r1','status':'running'}}
elif path.endswith('/branch') and method == 'POST':
conversations['branch'] = dict(conversation, id='branch', title='New branch'); messages['branch'] = []
data = {'conversation':conversations['branch']}
elif path.startswith('/api/conversations/'):
cid = path.split('/')[3]
if method == 'DELETE': del conversations[cid]; data = {'ok':True}
elif method == 'PATCH':
if body['revision'] != conversations[cid]['revision']:
r.fulfill(status=409,json={'error':'Stale revision'}); return
conversations[cid].update(body); conversations[cid]['revision'] += 1; data = {'conversation':conversations[cid]}
else: data = {'conversation':conversations[cid],'messages':messages[cid],'runs':[],'next_cursor':None}
elif path == '/api/auth/logout': data = {'ok':True}
else:
asset = ROOT / 'web' / (path.lstrip('/') or 'index.html')
if asset.is_file():
r.fulfill(body=asset.read_bytes(), content_type={'.html':'text/html','.css':'text/css','.js':'application/javascript','.svg':'image/svg+xml'}.get(asset.suffix,'application/octet-stream')); return
r.fulfill(status=404,json={'error':'Unavailable'}); return
r.fulfill(json=data)
page.route('https://hope.test/**', route)
return calls, streams, conversations, messages
def test_chat_crud_replay_safe_markdown_and_preserved_branches():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width':1280,'height':900})
errors=[]; page.on('pageerror',lambda error: errors.append(str(error)))
calls, streams, conversations, messages = fixture(page, replay=True)
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
page.get_by_role('button',name='A saved thought',exact=True).click()
expect(page.locator('#chatTimeline')).to_contain_text('Original question')
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
page.locator('#pinConversation').click(); expect(page.locator('#pinConversation')).to_have_text('Unpin')
page.locator('#primaryInput').fill('A new question'); page.locator('#primarySend').click()
expect(page.locator('#chatTimeline')).to_contain_text('Answer')
expect(page.locator('#primaryInput')).to_be_enabled(timeout=10000)
assert len(streams) == 2 and streams[-1].endswith('after=1')
assert page.locator('[data-message-id="a1"]').count() == 1
assert page.locator('#chatTimeline table').count() == 1
assert page.locator('#chatTimeline img').count() == 0
assert page.locator('#chatTimeline script').count() == 0
assert page.locator('#chatTimeline a[href^="javascript:"]').count() == 0
assert page.evaluate('window.injected') is None
assert not any(path == '/token' for _,path,_ in calls)
page.get_by_role('button',name='Edit in new branch').first.click()
page.locator('#chatActionValue').fill('Revised question'); page.locator('#chatActionSave').click()
expect(page.locator('#chatTitle')).to_have_text('New branch')
expect(page.locator('#chatTimeline')).to_contain_text('Revised question')
assert messages['c1'][0]['content'] == 'Original question'
page.locator('#openPreferences').click(); expect(page.locator('#preferencesMessage')).to_have_text(''); page.locator('#paletteSelect').select_option('ocean')
page.get_by_role('button',name='Save appearance').click(); expect(page.locator('html')).to_have_attribute('data-palette','ocean')
page.locator('#settingsClose').click(); page.screenshot(path='/tmp/hope-chat-desktop.png')
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
page.locator('#deleteConversation').click(); page.locator('#chatActionSave').click()
expect(page.locator('#chatTitle')).to_have_text('New conversation'); assert 'branch' not in conversations
assert errors == []
browser.close()
def test_mobile_drawer_keyboard_voice_and_logout():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width':390,'height':844}); fixture(page)
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
page.locator('#drawerOpen').click(); expect(page.locator('#drawerOpen')).to_have_attribute('aria-expanded','true')
page.keyboard.press('Escape'); expect(page.locator('#drawerOpen')).to_be_focused()
page.locator('#drawerOpen').click(); page.locator('#accountMenuBtn').click()
expect(page.locator('#settingsDialog')).to_be_visible()
page.locator('#logoutBtn').click()
expect(page.locator('#loginGate')).to_be_visible()
assert 'Original question' not in page.locator('#chatTimeline').inner_text()
browser.close()
def test_archived_move_sync_conflict_and_mobile_actions():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width':390,'height':844})
calls, streams, conversations, messages = fixture(page)
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
expect(page.locator('#conversationMenu')).to_be_hidden()
page.locator('#drawerOpen').click(); page.get_by_role('button',name='A saved thought',exact=True).click()
page.locator('#conversationMenu').click(); expect(page.locator('#moveConversation')).to_be_visible()
page.locator('#moveConversation').click(); page.locator('#chatWorkspaceSelect').select_option('w1'); page.locator('#chatActionSave').click()
expect(page.locator('#chatActionDialog')).not_to_be_visible()
assert conversations['c1']['workspace_id'] == 'w1'
conversations['c1'].update(title='Changed elsewhere', revision=4)
page.evaluate("document.dispatchEvent(new Event('visibilitychange'))")
expect(page.locator('#chatTitle')).to_have_text('Changed elsewhere')
page.locator('#conversationMenu').click(); page.locator('#archiveConversation').click(); page.locator('#chatActionSave').click()
expect(page.locator('#chatTitle')).to_have_text('New conversation')
page.locator('#drawerOpen').click(); page.locator('#showArchived').click()
page.get_by_role('button',name='Changed elsewhere',exact=True).click()
page.locator('#conversationMenu').click(); expect(page.locator('#archiveConversation')).to_have_text('Unarchive')
page.locator('#archiveConversation').click(); page.locator('#chatActionSave').click()
expect(page.locator('#chatTitle')).to_have_text('New conversation')
assert conversations['c1']['archived'] is False
# A stale optimistic write refreshes rather than overwriting another client's title.
page.locator('#drawerOpen').click(); page.locator('#showArchived').click()
page.get_by_role('button',name='Changed elsewhere',exact=True).click()
def conflict(route):
if route.request.method == 'PATCH':
conversations['c1'].update(title='Newest revision',revision=9)
route.fulfill(status=409,json={'error':'Stale revision'})
else: route.fallback()
page.route('https://hope.test/api/conversations/c1', conflict)
page.locator('#conversationMenu').click(); page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
page.locator('#pinConversation').click()
expect(page.locator('#chatTitle')).to_have_text('Newest revision')
expect(page.locator('#chatNotice')).to_contain_text('Conversation refreshed')
browser.close()
def test_voice_history_refresh_keyboard_focus_and_large_text():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width':1280,'height':900}, reduced_motion='reduce')
calls, streams, conversations, messages = fixture(page)
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
page.get_by_role('button',name='A saved thought',exact=True).click()
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
expect(page.locator('#chatTimeline')).to_contain_text('Original question')
page.locator('#primaryInput').fill('Keep my draft')
page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true,conversation_id:'c1'}}))")
messages['c1'].append(dict(id='voice1',role='assistant',content='Saved voice reply with [source](https://example.com/report)',status='completed'))
page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:false,conversation_id:'c1'}}))")
expect(page.locator('#chatTimeline')).to_contain_text('Saved voice reply')
expect(page.locator('#primaryInput')).to_have_value('Keep my draft')
expect(page.locator('#chatTimeline a')).to_have_attribute('href','https://example.com/report')
expect(page.locator('#chatNotice')).to_contain_text('Voice conversation saved')
page.screenshot(path='/tmp/hope-chat-opal-desktop.png')
page.keyboard.press('Control+k'); expect(page.locator('#conversationSearch')).to_be_focused()
page.keyboard.press('Escape')
page.locator('#openPreferences').click(); expect(page.locator('#settingsDialog')).to_be_visible()
page.keyboard.press('Escape'); expect(page.locator('#openPreferences')).to_be_focused()
# Native controls remain usable at large text and narrow effective viewport.
page.set_viewport_size({'width':640,'height':900})
page.add_style_tag(content='html {font-size: 200% !important;}')
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
page.locator('#drawerOpen').click(); page.keyboard.press('Escape')
expect(page.locator('#drawerOpen')).to_be_focused()
assert page.evaluate("getComputedStyle(document.getElementById('drawerOpen')).outlineStyle") != 'none'
browser.close()
def test_qualified_research_and_private_export():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(); calls, streams, conversations, messages = fixture(page)
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
expect(page.locator('#researchMode')).to_be_visible()
page.locator('#researchMode').select_option('research')
page.locator('#primaryInput').fill('Research this topic'); page.locator('#primarySend').click()
expect(page.locator('#chatTimeline')).to_contain_text('Answer')
assert next(body for method,path,body in calls if method == 'POST' and path.endswith('/messages'))['mode'] == 'research'
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
with page.expect_download() as info: page.locator('#exportMarkdown').click()
assert info.value.suggested_filename.endswith('.md')
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
with page.expect_download() as info: page.locator('#exportJSON').click()
assert info.value.suggested_filename.endswith('.json')
assert all('/api/conversations/' in path for method,path,body in calls if path.endswith('/export'))
browser.close()
def test_immediate_pin_after_response_uses_final_server_revision():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(); calls, streams, conversations, messages = fixture(page)
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
page.get_by_role('button',name='A saved thought',exact=True).click()
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
page.locator('#primaryInput').fill('Finish and immediately pin'); page.locator('#primarySend').click()
expect(page.locator('#chatTimeline')).to_contain_text('Answer')
page.evaluate("if (!document.getElementById('conversationActions').matches(':popover-open')) document.getElementById('conversationActions').showPopover()")
page.locator('#pinConversation').click()
expect(page.locator('#pinConversation')).to_have_text('Unpin')
assert conversations['c1']['pinned'] is True
assert 'Conversation refreshed' not in page.locator('#chatNotice').inner_text()
patch = next(body for method,path,body in calls if method == 'PATCH')
assert patch['revision'] == 2
browser.close()
def test_realtime_voice_is_inline_in_same_thread_and_creates_real_conversation():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
for width in [1440, 390, 320]:
page = browser.new_page(viewport={'width':width,'height':900})
errors = []; page.on('pageerror', lambda error: errors.append(str(error)))
calls, streams, conversations, messages = fixture(page)
page.add_init_script("window.addEventListener('hope:start-voice', event => {event.stopImmediatePropagation(); window.startedVoice = event.detail.conversation_id; window.dispatchEvent(new CustomEvent('hope:voice-state', {detail:{active:true,conversation_id:event.detail.conversation_id}}));}, true)")
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
# Replace only voice transport; durable conversation creation still uses real chat.js.
expect(page.locator('#primaryComposer #talkToHope')).to_be_visible()
assert page.locator('.chat-topbar #talkToHope').count() == 0
page.locator('#primaryInput').fill('Keep this draft while I talk')
page.locator('#talkToHope').click()
expect(page.locator('#legacyVoice')).to_be_visible()
expect(page.locator('#primaryChat')).to_be_visible()
expect(page.locator('#primaryInput')).to_have_value('Keep this draft while I talk')
expect(page.locator('#primaryInput')).to_be_enabled()
expect(page.locator('#primarySend')).to_be_disabled()
assert page.evaluate('window.startedVoice') == 'c2'
assert any(method == 'POST' and path == '/api/conversations' for method,path,body in calls)
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
page.screenshot(path=f'/tmp/hope-talk-visible-{width}.png')
assert not errors
page.close()
browser.close()