feat: clarify web library memory and conversation management
Rebuild & Deploy / rebuild (push) Failing after 17s
Rebuild & Deploy / rebuild (push) Failing after 17s
This commit is contained in:
1 parent
3183ebaea7
commit
41ca590744
11 files changed
+767
-70
No files matched your search
@@ -0,0 +1,219 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user