220 lines
12 KiB
Python
220 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}}))")
|
|
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()
|