"""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| | **safe** |\\n**Safe** and `clear`.\\n\\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()