Files
hope-voice-api/tests/unit/test_sidebar_actions_web.py
snowspeeder 8905dc2c2a
Rebuild & Deploy / rebuild (push) Successful in 3m7s
fix: give realtime voice a dedicated conversation view
2026-10-04 10:35:34 -04:00

222 lines
12 KiB
Python

"""Offline sidebar mutations using real assets and mocked authenticated APIs."""
from urllib.parse import urlparse, parse_qs
from playwright.sync_api import sync_playwright, expect
from test_chat_web import fixture
def setup(page):
calls, streams, conversations, messages = fixture(page)
conversations['c2'] = dict(conversations['c1'], id='c2', title='Another thought')
messages['c2'] = []
page.goto('https://hope.test/')
page.wait_for_load_state('networkidle')
if page.locator('#drawerOpen').is_visible():
page.locator('#drawerOpen').click()
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('Keep my selected draft')
return calls, conversations
def menu(page, title='Another thought'):
page.get_by_role('button', name='Actions for ' + title, exact=True).click()
return page.locator('.conversation-row-menu')
def test_nonselected_pin_rename_archive_unarchive_preserve_selected_chat():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width':1280, 'height':900})
calls, conversations = setup(page)
menu(page).get_by_role('button', name='Pin', exact=True).click()
expect(page.locator('#pinnedConversations')).to_contain_text('Another thought')
menu(page).get_by_role('button', name='Unpin', exact=True).click()
expect(page.locator('#recentConversations')).to_contain_text('Another thought')
menu(page).get_by_role('button', name='Rename', exact=True).click()
page.locator('#chatActionValue').fill('Renamed thought')
page.locator('#chatActionSave').click()
expect(page.locator('#chatActionDialog')).not_to_be_visible()
menu(page, 'Renamed thought').get_by_role('button', name='Archive', exact=True).click()
page.locator('#chatActionSave').click()
expect(page.locator('#recentConversations')).not_to_contain_text('Renamed thought')
page.locator('#showArchived').click()
menu(page, 'Renamed thought').get_by_role('button', name='Unarchive', exact=True).click()
page.locator('#chatActionSave').click()
expect(page.locator('#recentConversations')).not_to_contain_text('Renamed thought')
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
expect(page.locator('#primaryInput')).to_have_value('Keep my selected draft')
expect(page.locator('#chatTimeline')).to_contain_text('Original question')
patches = [body for method, path, body in calls if method == 'PATCH']
assert [body['revision'] for body in patches] == [1, 2, 3, 4, 5]
assert not any(method == 'GET' and path == '/api/conversations/c2' for method, path, body in calls)
assert conversations['c2']['archived'] is False
browser.close()
def test_nonselected_conflict_refresh_retry_and_failed_refresh_errors():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
calls, conversations = setup(page)
def conflict(route):
if route.request.method == 'PATCH':
conversations['c2'].update(title='Newest title', revision=8)
route.fulfill(status=409, json={'error':'Stale revision'})
else:
route.fallback()
page.route('https://hope.test/api/conversations/c2', conflict)
menu(page).get_by_role('button', name='Rename', exact=True).click()
page.locator('#chatActionValue').fill('My title')
page.locator('#chatActionSave').click()
expect(page.locator('#chatActionError')).to_contain_text('Stale revision')
expect(page.locator('#chatActionError')).to_contain_text('Conversation refreshed')
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
page.unroute('https://hope.test/api/conversations/c2', conflict)
page.locator('#chatActionSave').click()
expect(page.locator('#recentConversations')).to_contain_text('My title')
assert next(body for method, path, body in reversed(calls) if method == 'PATCH')['revision'] == 8
def unavailable(route):
route.fulfill(status=503, json={'error':'Service unavailable'})
page.route('https://hope.test/api/conversations/c2', unavailable)
menu(page, 'My title').get_by_role('button', name='Pin', exact=True).click()
expect(page.locator('#chatNotice')).to_contain_text('Service unavailable')
expect(page.locator('#chatNotice')).to_contain_text('Could not refresh')
expect(page.locator('#primaryInput')).to_have_value('Keep my selected draft')
browser.close()
def test_row_menu_keyboard_top_layer_and_selected_voice_safety():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width':390, 'height':844})
calls, conversations = setup(page)
page.locator('#drawerOpen').click()
trigger = page.get_by_role('button', name='Actions for Another thought', exact=True)
trigger.focus(); page.keyboard.press('Enter')
expect(page.locator('.conversation-row-menu')).to_be_visible()
expect(page.locator('.conversation-row-menu button').first).to_be_focused()
assert page.locator('.conversation-row-menu').evaluate("el => el.matches(':popover-open')")
assert page.locator('.conversation-row-menu').evaluate('el => {const r=el.getBoundingClientRect(); return r.left >= 0 && r.right <= innerWidth && r.top >= 0 && r.bottom <= innerHeight;}')
page.keyboard.press('Escape')
expect(page.locator('.conversation-row-menu')).not_to_be_visible()
page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true}}))")
expect(page.locator('#drawerOpen')).to_have_attribute('aria-expanded', 'false')
page.locator('#drawerOpen').click()
active = menu(page, 'A saved thought')
for name in ['Pin', 'Rename', 'Archive']:
expect(active.get_by_role('button', name=name, exact=True)).to_be_disabled()
page.keyboard.press('Escape')
if page.locator('#drawerOpen').get_attribute('aria-expanded') == 'false':
page.locator('#drawerOpen').click()
menu(page).get_by_role('button', name='Pin', exact=True).click()
expect(page.locator('#pinnedConversations')).to_contain_text('Another thought')
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
browser.close()
def test_nonselected_pin_preserves_selected_running_stream():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
calls, conversations = setup(page)
streams = []
page.route('https://hope.test/api/runs/r1/events?**', lambda route: streams.append(route))
with page.expect_request(lambda request: '/api/runs/r1/events?' in request.url):
page.locator('#primarySend').click()
expect(page.locator('#cancelRun')).to_be_visible()
expect(page.locator('#primaryInput')).to_be_disabled()
active = menu(page, 'A saved thought')
expect(active.get_by_role('button', name='Pin', exact=True)).to_be_disabled()
page.keyboard.press('Escape')
menu(page).get_by_role('button', name='Pin', exact=True).click()
expect(page.locator('#pinnedConversations')).to_contain_text('Another thought')
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
expect(page.locator('#cancelRun')).to_be_visible()
expect(page.locator('#primaryInput')).to_be_disabled()
assert page.evaluate('HopeChat.running') is True
assert streams
streams[0].fulfill(content_type='text/event-stream', body='id: 1\nevent: done\ndata: {"run":{"id":"r1","status":"completed"}}\n\n')
expect(page.locator('#primaryInput')).to_be_enabled()
browser.close()
def test_late_sidebar_patch_does_not_cross_account_epoch():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
calls, conversations = setup(page)
pending = []
def delayed(route):
if route.request.method == 'PATCH': pending.append(route)
else: route.fallback()
page.route('https://hope.test/api/conversations/c2', delayed)
with page.expect_request(lambda request: request.method == 'PATCH'):
menu(page).get_by_role('button', name='Pin', exact=True).click()
assert pending
page.evaluate('HopeAuth.signOut()')
pending[0].fulfill(status=409, json={'error':'Old account failure'})
expect(page.locator('#loginGate')).to_be_visible()
expect(page.locator('#chatNotice')).to_have_text('')
expect(page.locator('#recentConversations')).to_be_empty()
assert not any(method == 'GET' and path == '/api/conversations/c2' for method, path, body in calls)
browser.close()
def test_delayed_selected_archive_does_not_invalidate_pending_navigation():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
calls, conversations = setup(page)
patches, navigations = [], []
def delayed_patch(route):
if route.request.method == 'PATCH': patches.append(route)
else: route.fallback()
page.route('https://hope.test/api/conversations/c1', delayed_patch)
page.route('https://hope.test/api/conversations/c2', lambda route: navigations.append(route))
menu(page, 'A saved thought').get_by_role('button', name='Archive', exact=True).click()
with page.expect_request(lambda request: request.method == 'PATCH'):
page.locator('#chatActionSave').click()
# Dismiss the pending confirmation, then navigate while its write is still pending.
page.locator('#chatActionCancel').click()
with page.expect_request(lambda request: urlparse(request.url).path == '/api/conversations/c2'):
page.get_by_role('button', name='Another thought', exact=True).click()
expect(page.locator('#chatNotice')).to_have_text('Loading conversation…')
conversations['c1'].update(archived=True, revision=2)
patches[0].fulfill(json={'conversation':conversations['c1']})
expect(page.locator('#recentConversations')).not_to_contain_text('A saved thought')
expect(page.locator('#primaryInput')).to_have_value('Keep my selected draft')
navigations[0].fulfill(json={'conversation':conversations['c2'], 'messages':[], 'runs':[], 'next_cursor':None})
expect(page.locator('#chatTitle')).to_have_text('Another thought')
expect(page.locator('#primaryInput')).to_have_value('Keep my selected draft')
browser.close()
def test_mutation_restarts_pagination_in_current_search_workspace_scope():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
calls, conversations = setup(page)
queries = []
conversations['c2']['workspace_id'] = 'w1'
third = dict(conversations['c2'], id='c3', title='Another page')
def pages(route):
query = parse_qs(urlparse(route.request.url).query)
queries.append(query)
route.fulfill(json={'conversations':[third] if 'cursor' in query else [conversations['c2']], 'next_cursor':None if 'cursor' in query else 'page2'})
page.route('https://hope.test/api/conversations?**', pages)
page.get_by_role('button', name='Writing', exact=True).click()
with page.expect_response(lambda response: '/api/conversations?' in response.url and 'q=Another' in response.url):
page.locator('#conversationSearch').fill('Another')
expect(page.locator('#loadMoreConversations')).to_be_visible()
page.locator('#loadMoreConversations').click()
expect(page.locator('#recentConversations')).to_contain_text('Another page')
menu(page).get_by_role('button', name='Pin', exact=True).click()
expect(page.locator('#pinnedConversations')).to_contain_text('Another thought')
expect(page.locator('#recentConversations')).not_to_contain_text('Another page')
assert queries[-1]['q'] == ['Another']
assert queries[-1]['workspace_id'] == ['w1']
assert queries[-1]['archived'] == ['false']
assert 'cursor' not in queries[-1]
expect(page.locator('#loadMoreConversations')).to_be_visible()
browser.close()