Files
hope-voice-api/tests/unit/test_settings_web.py
2026-10-04 07:58:16 -04:00

245 lines
14 KiB
Python

"""Offline browser settings qualification: real assets, mocked same-origin APIs only."""
from pathlib import Path
from urllib.parse import urlparse
import pytest
from playwright.sync_api import sync_playwright, expect
ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture
def settings_page():
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': 390, 'height': 844})
state = {
'user': dict(id='alice', username='alice', display_name='Alice <script>', role='admin', status='ready'),
'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, 'capability_error': False, 'calls': [], 'errors': [],
}
page.on('pageerror', lambda error: state['errors'].append(str(error)))
def route(request):
path = urlparse(request.request.url).path
method = request.request.method
body = request.request.post_data_json if request.request.post_data else {}
state['calls'].append((method, path, body))
if path == '/api/auth/me':
if state['user'] is None:
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':
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']:
request.fulfill(status=503, json={'error': 'Appearance unavailable'}); return
if method == 'PUT':
assert body['revision'] == state['preferences']['revision']
state['preferences'].update(body)
state['preferences']['revision'] += 1
data = {'preferences': state['preferences']}
elif path == '/api/settings/model':
if method == 'PUT': state['model'].update(body)
data = {'settings': state['model'], 'catalog_available': True,
'models': [{'id': 'auto', 'name': 'Managed auto', 'thinking_levels': ['none', 'low', 'high']}]}
elif path == '/voices':
data = {'catalog_available': True, 'default_voice': 'en-US-Ava:DragonHDOmniLatestNeural',
'voices': [{'id': 'en-US-Ava:DragonHDOmniLatestNeural', 'name': 'Ava', 'locale': 'en-US', 'gender': 'Female', 'tag': 'Omni'}]}
elif path == '/api/admin/users':
if state['user']['role'] != 'admin':
request.fulfill(status=403, json={'error': 'Forbidden'}); return
if method == 'POST': state['users'].append(dict(body, id='new', status='provisioning', container_name='hope-hermes-new'))
data = {'users': state['users']}
elif path.startswith('/api/admin/users/'):
if method == 'DELETE': state['users'] = [user for user in state['users'] if user['id'] != path.split('/')[-1]]
data = {'ok': True}
elif path == '/api/memory': data = {'sections': [{'section': 'memory', 'content': 'Saved facts', 'revision': 'm1'}]}
elif path == '/api/memory/memory': data = {'revision': 'm2'}
elif path == '/api/conversations': data = {'conversations': [], 'next_cursor': None}
elif path == '/api/workspaces': data = {'workspaces': []}
elif path == '/api/sync': data = {'changes': [], 'cursor': 0}
else:
asset = ROOT / 'web' / (path.lstrip('/') or 'index.html')
if asset.is_file():
request.fulfill(body=asset.read_bytes(), content_type={'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.svg': 'image/svg+xml'}.get(asset.suffix, 'application/octet-stream')); return
request.fulfill(status=404, json={'error': 'Unavailable'}); return
request.fulfill(json=data)
page.route('https://hope.test/**', route)
page.clock.install()
page.goto('https://hope.test/')
page.wait_for_load_state('networkidle')
yield page, state
assert state['errors'] == []
page.close(); browser.close()
def open_section(page, name):
page.evaluate('(section) => HopeSettings.open(section)', name)
expect(page.locator('#settingsDialog')).to_be_visible()
expect(page.locator(f'[data-settings-panel="{name}"]')).to_be_visible()
def test_account_readonly_keyboard_focus_and_idempotent_mobile_navigation(settings_page):
page, state = settings_page
# Account settings remain reachable outside the voice-only legacy surface.
page.locator('#drawerOpen').click()
opener = page.locator('[data-settings-open="account"]').first
opener.click()
expect(page.locator('#settingsAccountName')).to_have_text('Alice <script>')
page.screenshot(path='/tmp/hope-settings-account-mobile.png', full_page=True)
assert page.locator('#settingsDialog script').count() == 0
assert page.locator('[data-settings-panel="account"] input:not([readonly])').count() == 0
page.evaluate('HopeSettings.initialize(); HopeSettings.initialize()')
page.locator('[data-settings-target="security"]').click()
expect(page.locator('[data-settings-target="security"]')).to_have_attribute('aria-current', 'page')
for _ in range(12):
page.keyboard.press('Tab')
assert page.evaluate('document.getElementById("settingsDialog").contains(document.activeElement)')
page.keyboard.press('Escape')
expect(page.locator('#settingsDialog')).not_to_be_visible()
expect(page.locator('#drawerOpen')).to_be_focused()
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
ids = page.evaluate('Array.from(document.querySelectorAll("[id]")).map(e=>e.id)')
assert len(ids) == len(set(ids))
assert not any(method in ('PUT', 'PATCH', 'POST') for method, _, _ in state['calls'])
def test_admin_create_reset_delete_keep_existing_auth_contracts(settings_page):
page, state = settings_page
open_section(page, 'admin')
expect(page.locator('#usersBody')).to_contain_text('Provisioning failed')
page.locator('#usersBody tr').filter(has_text='Bob').get_by_role('button', name='Retry provisioning', exact=True).click()
page.wait_for_function("document.getElementById('adminMessage').textContent.includes('refresh')")
page.locator('#newUsername').fill('charlie')
page.locator('#newDisplayName').fill('Charlie')
page.locator('#newPassword').fill('fictional-password-123')
page.locator('#newRole').select_option('user')
page.locator('#createUserSubmit').click()
expect(page.locator('#usersBody')).to_contain_text('Charlie')
assert page.locator('#newPassword').input_value() == ''
page.locator('#usersBody tr').filter(has_text='Bob').get_by_role('button', name='Reset password').click()
expect(page.locator('#accountDialog')).to_be_visible()
page.locator('#resetPassword').fill('replacement-password-123')
page.locator('#confirmAccountAction').click()
expect(page.locator('#accountDialog')).not_to_be_visible()
assert page.locator('#resetPassword').input_value() == ''
page.locator('#usersBody tr').filter(has_text='Bob').get_by_role('button', name='Delete', exact=True).click()
expect(page.locator('#accountDialogDescription')).to_contain_text('Retained private data is not erased')
page.locator('#confirmAccountAction').click()
expect(page.locator('#usersBody')).not_to_contain_text('Bob')
writes = [(method, path, body) for method, path, body in state['calls'] if method != 'GET']
assert writes == [
('POST', '/api/admin/users/bob/retry', {}),
('POST', '/api/admin/users', {'username': 'charlie', 'display_name': 'Charlie', 'password': 'fictional-password-123', 'role': 'user'}),
('POST', '/api/admin/users/bob/password', {'password': 'replacement-password-123'}),
('DELETE', '/api/admin/users/bob', {}),
]
def test_role_downgrade_and_logout_remove_privileged_navigation(settings_page):
page, state = settings_page
open_section(page, 'admin')
state['user'] = dict(state['user'], role='user')
# Poll through the actual auth owner instead of replacing its session state.
page.clock.fast_forward(11000)
expect(page.locator('[data-settings-target="admin"]')).to_be_hidden()
expect(page.locator('[data-settings-panel="account"]')).to_be_visible()
expect(page.locator('#settingsAccountRole')).to_have_text('User')
page.locator('#logoutBtn').click()
expect(page.locator('#settingsDialog')).not_to_be_visible()
expect(page.locator('#settingsAccountName')).to_have_text('')
before = len([call for call in state['calls'] if call[1] == '/api/admin/users'])
page.evaluate("HopeSettings.open('admin')")
assert not page.evaluate('HopeSettings.isOpen')
assert len([call for call in state['calls'] if call[1] == '/api/admin/users']) == before
def test_appearance_revision_save_failure_and_memory_real_controls(settings_page):
page, state = settings_page
open_section(page, 'appearance')
expect(page.locator('#preferencesMessage')).not_to_contain_text('Loading')
page.locator('#paletteSelect').select_option('ocean')
page.locator('#preferencesForm button[type="submit"], #preferencesForm button:not([type])').last.click()
expect(page.locator('html')).to_have_attribute('data-palette', 'ocean')
assert state['preferences']['revision'] == 2
state['appearance_error'] = True
open_section(page, 'appearance')
expect(page.locator('#preferencesMessage')).to_contain_text('Appearance unavailable')
open_section(page, 'privacy')
page.locator('[data-settings-panel="privacy"] summary').filter(has_text='Memory').click()
textarea = page.get_by_label('Assistant memory', exact=True)
expect(textarea).to_have_value('Saved facts')
textarea.fill('Revised private facts')
page.get_by_role('button', name='Save assistant memory', exact=True).click()
expect(page.locator('#featureStatus')).to_contain_text('saved')
assert ('PUT', '/api/memory/memory', {'content': 'Revised private facts', 'revision': 'm1'}) in state['calls']
state['memory'] = False
page.evaluate('HopeFeatures.refresh()')
expect(page.locator('#settingsMemoryStatus')).to_contain_text('unavailable')
page.locator('#settingsClose').click()
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}}))")
open_section(page, 'preferences')
expect(page.locator('#modelSelect')).to_be_enabled()
page.locator('#thinkingLevelSelect').select_option('high')
page.locator('#saveModelSettings').click()
expect(page.locator('#modelSettingsStatus')).to_contain_text('Saved')
page.locator('#settingsClose').click()
assert page.evaluate('voiceEvents') == [{'active': True}]
assert not any(path == '/token' for _, path, _ in state['calls'])
assert ('PUT', '/api/settings/model', {'model': 'auto', 'thinking_level': 'high'}) in state['calls']