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
@@ -5,7 +5,7 @@ import unittest
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
MOCK = r'''() => {
|
||||
window.calls = []; window.listeners = []; window.pendingUpload = false; window.conflict = false;
|
||||
window.calls = []; window.listeners = []; window.pendingUpload = false; window.conflict = false; window.uploadError = ''; window.extraction = 'ready'; window.file = {id:'c'.repeat(32),filename:'notes.txt',mime:'text/plain',size:5,extraction_status:'ready'};
|
||||
window.capabilities = {attachments:true, artifacts:true, scheduled_tasks:true, memory_controls:true, image_input:true,
|
||||
attachment_mimes:['text/plain','text/markdown','application/pdf','image/png'], upload_bytes:8388608};
|
||||
window.artifact = {id:'a'.repeat(32),title:'Draft <img src=x onerror=alert(1)>',kind:'markdown',revision:2,content:'<script>window.injected=true</script>'};
|
||||
@@ -16,11 +16,12 @@ MOCK = r'''() => {
|
||||
binary:options.body instanceof File,headers:options.headers});
|
||||
if (path==='/api/capabilities') return {features:capabilities};
|
||||
if (path==='/api/attachments') {
|
||||
if(options.method==='GET') return {attachments:[{id:'c'.repeat(32),filename:'notes.txt',size:5,extraction_status:'ready'}]};
|
||||
if(options.method==='GET') return {attachments:[file]};
|
||||
if(pendingUpload) await new Promise((resolve,reject)=>options.signal.addEventListener('abort',()=>reject(new DOMException('Stopped','AbortError'))));
|
||||
return {attachment:{id:'c'.repeat(32),filename:options.body.name,extraction_status:'ready'}};
|
||||
if(uploadError) throw new Error(uploadError); return {attachment:{id:'c'.repeat(32),filename:options.body.name,mime:options.body.type,size:options.body.size,extraction_status:extraction}};
|
||||
}
|
||||
if(path==='/api/artifacts') {const {content,...metadata}=artifact;return options.method==='POST'?{artifact:{...artifact,...JSON.parse(options.body),revision:1}}:{artifacts:[metadata]};}
|
||||
if(path.startsWith('/api/attachments/')) return {attachment:file};
|
||||
if(path==='/api/artifacts') {const {content,...metadata}=artifact;return options.method==='POST'?{artifact:{...artifact,...JSON.parse(options.body),revision:1}}:{artifacts:[metadata]};}
|
||||
if(path.startsWith('/api/artifacts/')) {
|
||||
if(options.method==='PATCH') {if(conflict) throw new Error('Artifact revision conflict');artifact={...artifact,...JSON.parse(options.body),revision:artifact.revision+1};}
|
||||
return {artifact};
|
||||
@@ -55,17 +56,165 @@ class ChatFeaturesWebTests(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.page = self.browser.new_page()
|
||||
self.page.set_content('<html><body><aside id="chatSidebar"></aside><form id="primaryComposer"><div id="featureActions"></div><div id="attachmentPreviews"></div></form></body></html>')
|
||||
self.page.set_content('<html><body><aside id="chatSidebar"></aside><dialog id="libraryDialog"><div id="libraryContent"></div></dialog><form id="primaryComposer"><textarea id="primaryInput"></textarea><button id="sendMock" type="submit">Send</button><div id="featureActions"></div><div id="attachmentPreviews"></div></form></body></html>')
|
||||
self.page.evaluate(MOCK)
|
||||
self.page.add_script_tag(path=str(ROOT / 'web/features.js'))
|
||||
self.page.wait_for_function('document.querySelector("[data-attach]").disabled === false')
|
||||
self.page.evaluate('document.getElementById("primaryComposer").addEventListener("submit",e=>{e.preventDefault();calls.push({path:"/api/conversations/mock/messages",method:"POST",body:{attachment_ids:HopeFeatures.attachmentIDs()}});HopeFeatures.clearAttachmentsAfterSend();})')
|
||||
|
||||
def tearDown(self):
|
||||
self.page.close()
|
||||
|
||||
def open_panel(self, name):
|
||||
self.page.evaluate('document.getElementById("libraryDialog").showModal()')
|
||||
self.page.get_by_text(name, exact=True).click()
|
||||
|
||||
def test_library_upload_queues_without_send(self):
|
||||
self.open_panel('Files')
|
||||
with self.page.expect_file_chooser() as chooser:
|
||||
self.page.get_by_role('button', name='Upload files', exact=True).click()
|
||||
chooser.value.set_files({'name':'library.txt','mimeType':'text/plain','buffer':b'Library'})
|
||||
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
||||
self.assertEqual(self.page.evaluate('calls.filter(c=>c.path.includes("/messages")).length'), 0)
|
||||
self.assertIn('nothing is sent until you send', self.page.locator('#hopeFeatures').text_content())
|
||||
self.assertFalse(self.page.evaluate('document.getElementById("libraryDialog").open'))
|
||||
self.assertEqual(self.page.evaluate('document.activeElement.id'), 'primaryInput')
|
||||
self.assertTrue(self.page.locator('#featureComposerStatus').is_visible())
|
||||
self.assertIn('library.txt', self.page.locator('#featureComposerStatus').inner_text())
|
||||
|
||||
def test_rejected_files_report_in_current_surface(self):
|
||||
self.open_panel('Files')
|
||||
with self.page.expect_file_chooser() as chooser:
|
||||
self.page.get_by_role('button', name='Upload files', exact=True).click()
|
||||
chooser.value.set_files({'name':'unsupported.svg','mimeType':'image/svg+xml','buffer':b'bad'})
|
||||
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("Unsupported")')
|
||||
self.assertTrue(self.page.evaluate('document.getElementById("libraryDialog").open'))
|
||||
self.assertIn('unsupported.svg', self.page.locator('.feature-section').filter(has=self.page.get_by_text('Files', exact=True)).locator('.feature-status').inner_text())
|
||||
self.page.evaluate('document.getElementById("libraryDialog").close()')
|
||||
self.page.evaluate('HopeFeatures.addFiles([new File([],"empty.txt",{type:"text/plain"})])')
|
||||
self.assertTrue(self.page.locator('#featureComposerStatus').is_visible())
|
||||
self.assertIn('empty.txt', self.page.locator('#featureComposerStatus').inner_text())
|
||||
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||
|
||||
def test_reuse_queues_then_explicit_send_and_focuses(self):
|
||||
self.open_panel('Files')
|
||||
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
||||
self.assertFalse(self.page.evaluate('document.getElementById("libraryDialog").open'))
|
||||
self.assertEqual(self.page.evaluate('document.activeElement.id'), 'primaryInput')
|
||||
self.assertEqual(self.page.evaluate('calls.filter(c=>c.method==="POST").length'), 0)
|
||||
self.assertIn('notes.txt queued', self.page.locator('#featureStatus').text_content())
|
||||
self.page.get_by_role('button', name='Send', exact=True).click()
|
||||
self.assertEqual(self.page.evaluate('calls.find(c=>c.path.includes("/messages")).body.attachment_ids'), ['c'*32])
|
||||
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||
|
||||
def test_reuse_checks_blocked_images_capacity_and_stale_metadata(self):
|
||||
self.page.evaluate('file.extraction_status="encrypted"')
|
||||
self.open_panel('Files')
|
||||
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("could not be extracted")')
|
||||
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||
self.page.evaluate('file.extraction_status="image";file.mime="image/png";capabilities.image_input=false;HopeFeatures.refresh()')
|
||||
self.page.get_by_role('button', name='Refresh files').click()
|
||||
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("Image input")')
|
||||
self.page.evaluate('file.extraction_status="ready";file.mime="text/plain";capabilities.image_input=true;capabilities.attachments_per_message=1;HopeFeatures.refresh()')
|
||||
self.page.get_by_role('button', name='Refresh files').click()
|
||||
self.page.evaluate('HopeFeatures.addFiles([new File(["x"],"pending.txt",{type:"text/plain"})]);pendingUpload=true')
|
||||
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
||||
self.page.evaluate('file.id="d".repeat(32);document.getElementById("libraryDialog").showModal()')
|
||||
self.page.get_by_role('button', name='Refresh files').click()
|
||||
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("at most 1")')
|
||||
self.page.evaluate('HopeFeatures.clearAttachmentsAfterSend();file.extraction_status="ready"')
|
||||
self.page.get_by_role('button', name='Refresh files').click()
|
||||
self.page.evaluate('file.extraction_status="failed"')
|
||||
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("could not be extracted")')
|
||||
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||
|
||||
def test_upload_error_blocked_and_reuse_account_teardown(self):
|
||||
self.page.evaluate('uploadError="Account storage is full"')
|
||||
self.page.locator('input[type=file]').set_input_files({'name':'full.txt','mimeType':'text/plain','buffer':b'Hello'})
|
||||
self.page.get_by_role('button', name='Retry upload').wait_for()
|
||||
self.assertTrue(self.page.evaluate('HopeFeatures.hasPendingUploads()'))
|
||||
self.page.evaluate('uploadError="";extraction="failed"')
|
||||
self.page.get_by_role('button', name='Retry upload').click()
|
||||
self.page.wait_for_function('document.getElementById("attachmentPreviews").textContent.includes("blocked")')
|
||||
self.assertTrue(self.page.evaluate('HopeFeatures.hasPendingUploads()'))
|
||||
self.page.evaluate('changeAccount({id:"owner-b",status:"ready"})')
|
||||
self.page.wait_for_function('document.querySelector("[data-attach]").disabled === false')
|
||||
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||
self.open_panel('Files')
|
||||
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
||||
self.page.evaluate('changeAccount(null)')
|
||||
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||
self.assertEqual(self.page.locator('.feature-editor').filter(has_text='Updated facts').count(), 0)
|
||||
|
||||
def test_reuse_inflight_account_change_cannot_queue_previous_account(self):
|
||||
self.page.evaluate('''() => {const original=HopeAuth.request;HopeAuth.request=async(path,options)=>{if(path.startsWith('/api/attachments/')){window.reuseSignal=options.signal;await new Promise(resolve=>window.resolveReuse=resolve);}return original(path,options);};}''')
|
||||
self.open_panel('Files')
|
||||
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||
self.page.wait_for_function('!!window.resolveReuse')
|
||||
self.page.evaluate('changeAccount({id:"owner-b",status:"ready"});resolveReuse()')
|
||||
self.page.wait_for_function('document.querySelector("[data-attach]").disabled === false')
|
||||
self.assertTrue(self.page.evaluate('reuseSignal.aborted'))
|
||||
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||
self.assertNotIn('queued', self.page.locator('#featureStatus').text_content())
|
||||
|
||||
def test_overlapping_capability_refresh_ignores_old_vision_response(self):
|
||||
self.page.evaluate('''() => {const original=HopeAuth.request;window.capabilityResolvers=[];HopeAuth.request=(path,options)=>path==='/api/capabilities'?new Promise(resolve=>capabilityResolvers.push(resolve)):original(path,options);window.oldRefresh=HopeFeatures.refresh();window.newRefresh=HopeFeatures.refresh();}''')
|
||||
self.page.evaluate('''() => {capabilityResolvers[1]({features:{...capabilities,image_input:false}});}''')
|
||||
self.page.evaluate('() => newRefresh')
|
||||
self.assertNotIn('image/png', self.page.locator('input[type=file]').get_attribute('accept'))
|
||||
self.page.evaluate('''() => {capabilityResolvers[0]({features:{...capabilities,image_input:true}});}''')
|
||||
self.page.evaluate('() => oldRefresh')
|
||||
self.assertNotIn('image/png', self.page.locator('input[type=file]').get_attribute('accept'))
|
||||
self.page.evaluate('HopeFeatures.addFiles([new File(["x"],"image.png",{type:"image/png"})])')
|
||||
self.assertEqual(self.page.evaluate('calls.filter(c=>c.path==="/api/attachments").length'), 0)
|
||||
self.assertIn('Image input is not available', self.page.locator('#featureComposerStatus').inner_text())
|
||||
|
||||
def test_delayed_reuse_discarded_on_thread_change(self):
|
||||
self.page.evaluate("window.dispatchEvent(new CustomEvent('hope:conversation-selected',{detail:{conversation_id:'one'}}))")
|
||||
self.page.evaluate("""() => {const original=HopeAuth.request;HopeAuth.request=async(path,options)=>{if(path.startsWith('/api/attachments/')) await new Promise(resolve=>window.resolveReuse=resolve); return original(path,options);};}""")
|
||||
self.open_panel('Files')
|
||||
self.page.get_by_role('button', name='Use in conversation', exact=True).click()
|
||||
self.page.wait_for_function('!!window.resolveReuse')
|
||||
self.page.evaluate("window.dispatchEvent(new CustomEvent('hope:conversation-selected',{detail:{conversation_id:'two'}}));resolveReuse()")
|
||||
self.page.wait_for_function('document.querySelector(".feature-row-actions button").disabled===false')
|
||||
self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
|
||||
|
||||
def test_real_memory_conflict_requires_explicit_reviewed_revision(self):
|
||||
self.page.evaluate("""() => {window.latestMemory={section:'memory',content:'Saved facts',revision:'m1'};const original=HopeAuth.request;HopeAuth.request=async(path,options)=>{if(path==='/api/memory')return {sections:[{...latestMemory}]};if(path==='/api/memory/memory'){const data=JSON.parse(options.body);calls.push({path,body:data,method:'PUT'});if(data.revision!==latestMemory.revision)throw Object.assign(new Error('Memory revision conflict. Compare latest saved memory before retrying.'),{status:409});latestMemory={...latestMemory,content:data.content,revision:'m3'};return latestMemory;}return original(path,options);};}""")
|
||||
self.open_panel('Memory')
|
||||
content = self.page.get_by_label('Assistant memory', exact=True)
|
||||
content.fill('My retained local draft')
|
||||
self.page.evaluate('latestMemory={section:"memory",content:"Concurrent assistant edit",revision:"m2"}')
|
||||
self.page.get_by_role('button', name='Save assistant memory', exact=True).click()
|
||||
self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("retained")')
|
||||
self.page.get_by_role('button', name='Compare latest saved memory', exact=True).click()
|
||||
self.page.get_by_text('Concurrent assistant edit', exact=True).wait_for()
|
||||
self.assertEqual(content.input_value(), 'My retained local draft')
|
||||
self.page.get_by_role('button', name='Save assistant memory', exact=True).click()
|
||||
self.assertEqual(self.page.evaluate('latestMemory.content'), 'Concurrent assistant edit')
|
||||
self.page.get_by_role('button', name='Use reviewed revision for next save', exact=True).click()
|
||||
self.page.get_by_role('button', name='Save assistant memory', exact=True).click()
|
||||
self.page.wait_for_function('latestMemory.content==="My retained local draft"')
|
||||
self.assertEqual(self.page.evaluate('calls.filter(c=>c.path==="/api/memory/memory").at(-1).body.revision'), 'm2')
|
||||
|
||||
def test_reusable_structures_and_task_hints_explicit_paused(self):
|
||||
self.open_panel('Scheduled tasks')
|
||||
self.page.get_by_role('button', name='New scheduled task').click()
|
||||
self.assertTrue(self.page.get_by_label('Create paused', exact=True).is_checked())
|
||||
self.assertTrue(self.page.get_by_label('Create paused', exact=True).is_visible())
|
||||
self.assertEqual(self.page.locator('.feature-form .feature-field').count(), 5)
|
||||
self.assertIn('timezone and next run are not inferred', self.page.locator('.feature-form').inner_text())
|
||||
self.page.evaluate('const target=document.createElement("div");target.id="movedMemory";document.body.append(target);HopeFeatures.mountMemory(target);document.getElementById("libraryDialog").close();HopeFeatures.openSection("memory")')
|
||||
self.page.get_by_label('About you', exact=True).wait_for()
|
||||
self.assertEqual(self.page.locator('#movedMemory .feature-section').count(), 1)
|
||||
self.assertIn('For example', self.page.locator('#movedMemory').inner_text())
|
||||
|
||||
def test_upload_raw_bytes_and_ack_only_clear(self):
|
||||
self.page.locator('input[type=file]').set_input_files({'name':'notes.txt','mimeType':'text/plain','buffer':b'Hello'})
|
||||
self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
|
||||
@@ -82,7 +231,7 @@ class ChatFeaturesWebTests(unittest.TestCase):
|
||||
|
||||
def test_retained_upload_management_delete_is_explicit(self):
|
||||
self.open_panel('Files')
|
||||
self.page.get_by_text('Delete notes.txt',exact=True).click()
|
||||
self.page.locator('summary[aria-label="Delete notes.txt"]').click()
|
||||
self.assertIn("old chat attachment references won't work", self.page.locator('#hopeFeatures').inner_text())
|
||||
self.assertEqual(self.page.evaluate('calls.filter(c=>c.method==="DELETE").length'), 0)
|
||||
self.assertTrue(self.page.get_by_role('link',name='Download notes.txt').get_attribute('href').endswith('/content'))
|
||||
@@ -116,7 +265,7 @@ class ChatFeaturesWebTests(unittest.TestCase):
|
||||
self.assertTrue(self.page.locator('#hopeFeatures').is_hidden())
|
||||
|
||||
def test_artifact_safe_preview_versions_conflict_and_export(self):
|
||||
self.open_panel('Artifacts')
|
||||
self.open_panel('Saved drafts')
|
||||
self.page.get_by_role('button',name='Edit Draft',exact=False).click()
|
||||
self.page.get_by_label('Content',exact=True).fill('<img src=x onerror=alert(1)> changed')
|
||||
self.assertEqual(self.page.locator('pre').inner_text(), '<img src=x onerror=alert(1)> changed')
|
||||
|
||||
@@ -18,7 +18,7 @@ def settings_page():
|
||||
'users': [dict(id='bob', username='bob', display_name='Bob', role='user', status='error', container_name='hope-hermes-bob', error='Provisioning failed')],
|
||||
'preferences': dict(palette='opal', appearance='light', revision=1),
|
||||
'model': dict(model='auto', thinking_level='none'),
|
||||
'memory': True, 'appearance_error': False, 'calls': [], 'errors': [],
|
||||
'memory': True, 'appearance_error': False, 'capability_error': False, 'calls': [], 'errors': [],
|
||||
}
|
||||
page.on('pageerror', lambda error: state['errors'].append(str(error)))
|
||||
|
||||
@@ -32,7 +32,10 @@ def settings_page():
|
||||
request.fulfill(status=401, json={'error': 'Sign in required'}); return
|
||||
data = {'user': state['user']}
|
||||
elif path == '/api/auth/logout': data = {'ok': True}
|
||||
elif path == '/api/capabilities': data = {'features': {'memory_controls': state['memory']}}
|
||||
elif path == '/api/capabilities':
|
||||
if state['capability_error']:
|
||||
request.fulfill(status=503, json={'error': 'File support unavailable'}); return
|
||||
data = {'features': {'memory_controls': state['memory']}}
|
||||
elif path == '/api/research': data = {'available': False}
|
||||
elif path == '/api/preferences':
|
||||
if state['appearance_error']:
|
||||
@@ -185,6 +188,48 @@ def test_appearance_revision_save_failure_and_memory_real_controls(settings_page
|
||||
assert page.locator('[data-settings-panel="privacy"] #featureStatus').count() == 0
|
||||
|
||||
|
||||
def test_mobile_activity_closes_drawer_and_task_fields_stack(settings_page):
|
||||
page, state = settings_page
|
||||
page.locator('#drawerOpen').click()
|
||||
page.locator('#tasksBtn').click()
|
||||
expect(page.locator('#chatSidebar')).not_to_have_class('chat-sidebar drawer-open')
|
||||
expect(page.locator('#drawerBackdrop')).to_be_hidden()
|
||||
expect(page.locator('#tasksPanel')).to_be_visible()
|
||||
expect(page.locator('#closeTasksBtn')).to_be_focused()
|
||||
page.locator('#closeTasksBtn').click()
|
||||
expect(page.locator('#drawerOpen')).to_be_focused()
|
||||
|
||||
|
||||
def test_model_save_file_support_failure_has_working_retry(settings_page):
|
||||
page, state = settings_page
|
||||
open_section(page, 'preferences')
|
||||
state['capability_error'] = True
|
||||
page.locator('#thinkingLevelSelect').select_option('high')
|
||||
page.locator('#saveModelSettings').click()
|
||||
expect(page.locator('#modelSettingsStatus')).to_contain_text('file support could not be checked')
|
||||
expect(page.locator('#retryModelSettings')).to_be_visible()
|
||||
before = len([call for call in state['calls'] if call[1] == '/api/capabilities'])
|
||||
state['capability_error'] = False
|
||||
page.locator('#retryModelSettings').click()
|
||||
expect(page.locator('#modelSettingsStatus')).to_contain_text('saved settings are loaded')
|
||||
expect(page.locator('#retryModelSettings')).to_be_hidden()
|
||||
page.wait_for_function("document.getElementById('featureAvailability').textContent.includes('Files are unavailable.')")
|
||||
assert len([call for call in state['calls'] if call[1] == '/api/capabilities']) > before
|
||||
|
||||
|
||||
def test_security_explains_real_connection_and_signs_out(settings_page):
|
||||
page, state = settings_page
|
||||
open_section(page, 'security')
|
||||
expect(page.locator('#securityServer')).to_have_text('hope.test')
|
||||
expect(page.locator('#securityConnection')).to_contain_text('HTTPS')
|
||||
expect(page.locator('#securityIdentity')).to_contain_text('@alice')
|
||||
assert page.locator('[data-settings-panel="security"] input').count() == 0
|
||||
page.get_by_role('button', name='Sign out of this browser', exact=True).click()
|
||||
expect(page.locator('#settingsDialog')).not_to_be_visible()
|
||||
expect(page.locator('#loginGate')).to_be_visible()
|
||||
assert any(method == 'POST' and path == '/api/auth/logout' for method, path, _ in state['calls'])
|
||||
|
||||
|
||||
def test_preferences_preserve_active_voice_and_model_owner(settings_page):
|
||||
page, state = settings_page
|
||||
page.evaluate("window.voiceEvents=[];window.addEventListener('hope:voice-state',e=>voiceEvents.push(e.detail));window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true}}))")
|
||||
|
||||
@@ -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