feat: integrate production account settings navigation
This commit is contained in:
1 parent
02ea1d0884
commit
fef3cb35a8
2 files changed
+349
No files matched your search
@@ -0,0 +1,199 @@
|
||||
"""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, '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': 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_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']
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
// Settings reuses the account, appearance, voice and memory owners.
|
||||
// Moving nodes preserves state and handlers; this module never writes profiles or roles.
|
||||
(() => {
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
const sections = new Set(['account', 'appearance', 'preferences', 'privacy', 'security', 'admin']);
|
||||
let initialized = false, dialog, current = 'account', opener = null, generation = 0;
|
||||
let memory = null, statusHome = null;
|
||||
const panel = section => dialog?.querySelector(`[data-settings-panel="${section}"]`);
|
||||
const text = (id, value) => { if ($(id)) $(id).textContent = value; };
|
||||
|
||||
function restoreStatus() {
|
||||
if (statusHome && $('featureStatus')) {
|
||||
const {parent, next} = statusHome;
|
||||
parent.insertBefore($('featureStatus'), next?.parentNode === parent ? next : null);
|
||||
statusHome = null;
|
||||
}
|
||||
}
|
||||
function updateMemoryStatus() {
|
||||
text('settingsMemoryStatus', !memory || memory.hidden
|
||||
? 'Memory controls are unavailable for this account. No memory has been changed.'
|
||||
: 'Review or edit your saved memory below. Clearing memory does not delete conversations.');
|
||||
}
|
||||
function identity(user, changed) {
|
||||
text('settingsAccountName', user?.display_name || user?.username || '');
|
||||
text('settingsAccountUsername', user ? `@${user.username}` : '');
|
||||
text('settingsAccountRole', user ? (user.role === 'admin' ? 'Administrator' : 'User') : '');
|
||||
dialog.querySelectorAll('[data-settings-target="admin"], [data-settings-open="admin"]').forEach(button => { button.hidden = user?.role !== 'admin'; });
|
||||
if (!user || changed) close(false);
|
||||
else if (current === 'admin' && user.role !== 'admin') select('account');
|
||||
updateMemoryStatus();
|
||||
}
|
||||
async function loadAppearance(version) {
|
||||
const message = $('preferencesMessage');
|
||||
if (message) message.textContent = 'Loading your saved appearance…';
|
||||
try {
|
||||
if (window.HopeAppearance?.load) await window.HopeAppearance.load();
|
||||
else throw new Error('Appearance settings are unavailable. Reload Hope to try again.');
|
||||
} catch (error) {
|
||||
if (version === generation && window.HopeAuth?.user && message) message.textContent = error.message;
|
||||
}
|
||||
}
|
||||
function select(section) {
|
||||
if (!sections.has(section)) section = 'account';
|
||||
if (section === 'admin' && window.HopeAuth?.user?.role !== 'admin') section = 'account';
|
||||
current = section; const version = ++generation;
|
||||
restoreStatus();
|
||||
dialog.querySelectorAll('[data-settings-panel]').forEach(item => { item.hidden = item.dataset.settingsPanel !== section; });
|
||||
dialog.querySelectorAll('[data-settings-target]').forEach(button => {
|
||||
const active = button.dataset.settingsTarget === section;
|
||||
button.classList.toggle('selected', active);
|
||||
if (active) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current');
|
||||
});
|
||||
// auth.js keeps its polling and provisioning handlers; never copy them.
|
||||
if ($('adminSection')) $('adminSection').hidden = section !== 'admin';
|
||||
$('adminBtn')?.setAttribute('aria-expanded', String(section === 'admin'));
|
||||
if (section === 'admin') $('refreshUsers')?.click();
|
||||
if (section === 'appearance') loadAppearance(version);
|
||||
if (section === 'preferences') {
|
||||
if (window.HopeVoiceSettings?.load) window.HopeVoiceSettings.load();
|
||||
else {
|
||||
if (typeof renderVoiceList === 'function') renderVoiceList();
|
||||
if (typeof loadVoiceCatalog === 'function') loadVoiceCatalog();
|
||||
if (typeof loadModelSettings === 'function') loadModelSettings();
|
||||
}
|
||||
}
|
||||
if (section === 'privacy') {
|
||||
if (!memory) memory = window.HopeFeatures?.mountMemory?.($('privacyMemory') || panel('privacy')) || null;
|
||||
const status = $('featureStatus');
|
||||
if (status && status.parentNode !== panel('privacy')) {
|
||||
statusHome = {parent: status.parentNode, next: status.nextSibling};
|
||||
panel('privacy').append(status);
|
||||
}
|
||||
updateMemoryStatus();
|
||||
}
|
||||
}
|
||||
function open(section = 'account') {
|
||||
initialize();
|
||||
if (!dialog || !window.HopeAuth?.user) return;
|
||||
if (!dialog.open) opener = document.activeElement;
|
||||
if ($('chatSidebar')?.classList.contains('drawer-open')) $('drawerClose')?.click();
|
||||
select(section);
|
||||
if (!dialog.open) dialog.showModal();
|
||||
$('settingsClose')?.focus();
|
||||
}
|
||||
function close(restoreFocus = true) {
|
||||
generation++; restoreStatus();
|
||||
if (!dialog?.open) return;
|
||||
dialog.close();
|
||||
if ($('adminSection')) $('adminSection').hidden = true;
|
||||
$('adminBtn')?.setAttribute('aria-expanded', 'false');
|
||||
if (restoreFocus) {
|
||||
const target = [opener, $('settingsBtn'), $('drawerOpen')].find(item => item?.isConnected && !item.closest('[hidden], [inert]') && item.getClientRects().length);
|
||||
target?.focus();
|
||||
}
|
||||
}
|
||||
function initialize() {
|
||||
if (initialized) return;
|
||||
dialog = $('settingsDialog');
|
||||
if (!dialog || !window.HopeAuth) return;
|
||||
initialized = true;
|
||||
const sheet = $('settingsSheet');
|
||||
if (sheet && panel('preferences')) {
|
||||
panel('preferences').append(sheet);
|
||||
sheet.hidden = false; sheet.inert = false;
|
||||
sheet.classList.remove('open');
|
||||
sheet.removeAttribute('role'); sheet.removeAttribute('aria-modal');
|
||||
}
|
||||
if ($('preferencesForm') && panel('appearance')) panel('appearance').append($('preferencesForm'));
|
||||
if ($('adminSection') && panel('admin')) panel('admin').append($('adminSection'));
|
||||
for (const id of ['closeSheetBtn', 'closePreferences']) {
|
||||
if ($(id)) $(id).addEventListener('click', () => close(), true);
|
||||
}
|
||||
$('settingsClose')?.addEventListener('click', () => close());
|
||||
dialog.addEventListener('cancel', event => { event.preventDefault(); close(); });
|
||||
dialog.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Tab' || $('accountDialog')?.open) return;
|
||||
const controls = [...dialog.querySelectorAll('button, input, select, textarea, a[href], [tabindex]')].filter(item => !item.disabled && item.tabIndex >= 0 && !item.closest('[hidden], [inert]') && item.getClientRects().length);
|
||||
const first = controls[0], last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
||||
});
|
||||
dialog.addEventListener('close', () => {
|
||||
// Native close events are queued; a later open must not be dismantled.
|
||||
if (dialog.open) return;
|
||||
restoreStatus();
|
||||
if ($('adminSection')) $('adminSection').hidden = true;
|
||||
$('adminBtn')?.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
dialog.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-settings-target]');
|
||||
if (!button || !dialog.contains(button)) return;
|
||||
event.preventDefault(); event.stopPropagation(); select(button.dataset.settingsTarget);
|
||||
}, true);
|
||||
document.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-settings-open], #adminBtn, #openPreferences');
|
||||
if (!button || dialog.contains(button)) return;
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
open(button.dataset.settingsOpen || (button.id === 'adminBtn' ? 'admin' : 'appearance'));
|
||||
}, true);
|
||||
dialog.querySelectorAll('[data-settings-signout]').forEach(button => {
|
||||
button.addEventListener('click', () => { close(false); $('logoutBtn')?.click(); });
|
||||
});
|
||||
memory = window.HopeFeatures?.mountMemory?.($('privacyMemory') || panel('privacy')) || null;
|
||||
if (memory) new MutationObserver(updateMemoryStatus).observe(memory, {attributes: true, attributeFilter: ['hidden']});
|
||||
window.HopeAuth.onChange(identity);
|
||||
}
|
||||
window.HopeSettings = {open, close, initialize, get section() {return current;}, get isOpen() {return !!dialog?.open;}};
|
||||
initialize();
|
||||
})();
|
||||
Reference in new issue
Block a user