"""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()