feat(settings): sync a Hermes Dashboard chip with Appearance dropdown (#4936)
Adds a "Hermes Dashboard" chip to the Settings > Appearance "Sidebar tabs" grid, bidirectionally synced with the existing dashboard-mode dropdown (never/always/auto). Chip off -> dropdown "never"; chip on -> restore last non-never mode; dropdown change -> chip re-render. The chip is a proxy for the dashboard config via saveDashboardSettings -> POST /api/dashboard/config; it NEVER mutates hidden_tabs/tab_order (separate toggle fn from _toggleTabVisibilityChip). loadDashboardSettings guards a stale config GET from clobbering a newer save (_dashboardSettingsLoadSeq / _dashboardSettingsWriteSeq), and a failed chip save rolls the dropdown/chip back. Chip label via textContent (XSS-safe). Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
parent
aa6eb35de7
commit
5bb03a6322
5 changed files with 823 additions and 3 deletions
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- **The Hermes Dashboard now has a show/hide chip in Settings → Appearance.** The Appearance → "Sidebar tabs" grid (where you toggle which tabs appear in the sidebar/rail) gains a "Hermes Dashboard" chip alongside the others, bidirectionally synced with the existing dashboard-mode dropdown (never/always/auto): toggling the chip off sets the dropdown to "never," toggling it on restores the last non-never mode, and changing the dropdown updates the chip. The chip is a proxy for the dashboard config — it never mutates the sidebar `hidden_tabs`/`tab_order` state — and the sync is guarded against a stale config GET clobbering a newer save, with a clean rollback if the save fails. Thanks @rodboev. (#4936, fixes #2724)
|
||||
|
||||
- **Settings now exposes a "Max output tokens" field.** The streaming path already honored a `max_tokens` cap from the active profile's `config.yaml`, but there was no way to manage it from the UI. Settings → Preferences now has a "Max output tokens" field that writes a root-level `max_tokens` override through the profile config (not `settings.json`, which the streaming worker never reads for this), preserving unrelated YAML keys. Blank clears the override so the existing `agent.max_tokens` fallback resumes; the field surfaces the effective fallback so you can see what a new turn would currently use. Thanks @rodboev. (#5010, fixes #2929)
|
||||
|
||||
- **Cross-container Docker setups report gateway health correctly again.** When WebUI and the Hermes agent run in separate containers, the agent-health check resolves the gateway's runtime status from a shared `gateway_state.json`. After the keyword-form `read_runtime_status(pid_path=…)` call fails against an older agent signature, the positional fallback was handed the sibling `gateway.pid` path instead of the runtime-status file — so the reader parsed PID metadata as runtime metadata and `gateway_state`/`updated_at` never resolved, breaking the freshness check. The fallback now passes the correct `gateway_state.json` sibling path (`pid_path.with_name(_RUNTIME_STATUS_FILE)`), with `gateway_state.json` as the default when the agent doesn't expose the filename. The keyword happy-path and normal PID-based health are unchanged. Thanks @rodboev. (#5065, fixes #5030)
|
||||
|
|
|
|||
|
|
@ -6616,6 +6616,34 @@ function _orderedSidebarPanels(order){
|
|||
return out;
|
||||
}
|
||||
|
||||
function _dashboardPanelMode(){
|
||||
var modeEl=$('settingsDashboardMode');
|
||||
var mode=modeEl&&modeEl.value;
|
||||
return mode==='never'||mode==='always'||mode==='auto'?mode:'auto';
|
||||
}
|
||||
|
||||
function _isDashboardChipOn(){
|
||||
return _dashboardPanelMode()!=='never';
|
||||
}
|
||||
|
||||
function _renderDashboardVisibilityChip(container){
|
||||
if(!container)return null;
|
||||
var chip=document.createElement('button');
|
||||
chip.type='button';
|
||||
chip.className='tab-visibility-chip';
|
||||
chip.setAttribute('data-tab-panel','__hermes_dashboard__');
|
||||
chip.setAttribute('role','switch');
|
||||
var isOn=_isDashboardChipOn();
|
||||
chip.setAttribute('aria-checked',isOn?'true':'false');
|
||||
if(!isOn) chip.classList.add('chip-off');
|
||||
chip.textContent=typeof t==='function'?t('tab_dashboard'):'Dashboard';
|
||||
chip.onclick=function(){
|
||||
if(Date.now()<_tabVisibilityDragSuppressUntil)return;
|
||||
_toggleDashboardVisibilityChip();
|
||||
};
|
||||
return chip;
|
||||
}
|
||||
|
||||
function _applyTabOrder(order){
|
||||
var ordered=_orderedSidebarPanels(order);
|
||||
['.rail','.sidebar-nav'].forEach(function(selector){
|
||||
|
|
@ -6688,6 +6716,8 @@ function _renderTabVisibilityChips(){
|
|||
_wireTabChipDrag(chip,panel);
|
||||
container.appendChild(chip);
|
||||
});
|
||||
var dashboardChip=_renderDashboardVisibilityChip(container);
|
||||
if(dashboardChip) container.appendChild(dashboardChip);
|
||||
}
|
||||
|
||||
function _wireTabChipDrag(chip,panel){
|
||||
|
|
@ -6742,6 +6772,21 @@ function _toggleTabVisibilityChip(panel){
|
|||
_scheduleAppearanceAutosave();
|
||||
}
|
||||
|
||||
function _toggleDashboardVisibilityChip(){
|
||||
var modeEl=$('settingsDashboardMode');
|
||||
if(!modeEl||typeof saveDashboardSettings!=='function') return;
|
||||
var currentMode=_dashboardPanelMode();
|
||||
var nextMode=currentMode==='never'
|
||||
? (typeof _getDashboardChipRestoreMode==='function' ? _getDashboardChipRestoreMode() : 'auto')
|
||||
: 'never';
|
||||
var previousMode=currentMode;
|
||||
modeEl.value=nextMode;
|
||||
Promise.resolve(saveDashboardSettings({raiseOnError:true})).catch(function(){
|
||||
modeEl.value=previousMode;
|
||||
if(typeof _renderTabVisibilityChips==='function') _renderTabVisibilityChips();
|
||||
});
|
||||
}
|
||||
|
||||
function _ensureComposerControlVisibilityState(settings){
|
||||
const fromSettings=(typeof _composerControlVisibilityFromSettings==='function')
|
||||
? _composerControlVisibilityFromSettings(settings||{})
|
||||
|
|
|
|||
36
static/ui.js
36
static/ui.js
|
|
@ -1174,11 +1174,28 @@ async function jumpToTurnQuestion(questionRawIdx, assistantRawIdx){
|
|||
const DASHBOARD_STATUS_TTL_MS=60000;
|
||||
let _dashboardStatusCache=null;
|
||||
let _dashboardStatusFetchedAt=0;
|
||||
let _dashboardLastNonNeverMode='auto'; // Server-scoped dashboard config keeps this restore target session-global on purpose.
|
||||
let _dashboardSettingsLoadSeq=0;
|
||||
let _dashboardSettingsWriteSeq=0;
|
||||
|
||||
function _dashboardIsBrowserLoopback(){
|
||||
const host=(window.location.hostname||'').replace(/^\[|\]$/g,'').toLowerCase();
|
||||
return host==='127.0.0.1'||host==='localhost'||host==='::1';
|
||||
}
|
||||
|
||||
function _normalizeDashboardEnabledMode(mode){
|
||||
return mode==='auto'||mode==='always'||mode==='never'?mode:'auto';
|
||||
}
|
||||
|
||||
function _setDashboardModeForChip(mode){
|
||||
mode=_normalizeDashboardEnabledMode(mode);
|
||||
if(mode==='auto'||mode==='always') _dashboardLastNonNeverMode=mode;
|
||||
}
|
||||
|
||||
function _getDashboardChipRestoreMode(){
|
||||
return _dashboardLastNonNeverMode||'auto';
|
||||
}
|
||||
|
||||
function _dashboardBrowserUrl(status){
|
||||
if(!status||!status.running) return '';
|
||||
if(status.browser_url||status.url){
|
||||
|
|
@ -1234,26 +1251,39 @@ async function loadDashboardSettings(){
|
|||
const modeEl=$('settingsDashboardMode');
|
||||
const urlEl=$('settingsDashboardUrl');
|
||||
if(!modeEl&&!urlEl) return;
|
||||
const loadSeq=++_dashboardSettingsLoadSeq;
|
||||
const writeSeq=_dashboardSettingsWriteSeq;
|
||||
try{
|
||||
const cfg=await api('/api/dashboard/config');
|
||||
if(modeEl) modeEl.value=cfg.enabled||'auto';
|
||||
if(loadSeq!==_dashboardSettingsLoadSeq||writeSeq!==_dashboardSettingsWriteSeq) return;
|
||||
const mode=_normalizeDashboardEnabledMode(cfg&&cfg.enabled);
|
||||
if(modeEl) modeEl.value=mode;
|
||||
_setDashboardModeForChip(mode);
|
||||
if(urlEl) urlEl.value=cfg.url||'';
|
||||
if(typeof _renderTabVisibilityChips==='function') _renderTabVisibilityChips();
|
||||
}catch(_){/* leave defaults visible */}
|
||||
}
|
||||
async function saveDashboardSettings(){
|
||||
async function saveDashboardSettings(opts){
|
||||
opts=opts||{};
|
||||
const modeEl=$('settingsDashboardMode');
|
||||
const urlEl=$('settingsDashboardUrl');
|
||||
const statusEl=$('settingsDashboardStatus');
|
||||
const payload={enabled:(modeEl&&modeEl.value)||'auto',url:(urlEl&&urlEl.value||'').trim()};
|
||||
_dashboardSettingsWriteSeq+=1;
|
||||
try{
|
||||
const saved=await api('/api/dashboard/config',{method:'POST',body:JSON.stringify(payload)});
|
||||
if(modeEl) modeEl.value=saved.enabled||'auto';
|
||||
const mode=_normalizeDashboardEnabledMode(saved&&saved.enabled);
|
||||
if(modeEl) modeEl.value=mode;
|
||||
_setDashboardModeForChip(mode);
|
||||
if(urlEl) urlEl.value=saved.url||'';
|
||||
if(statusEl) statusEl.textContent='Dashboard link settings saved.';
|
||||
await refreshDashboardStatus(true);
|
||||
if(typeof _renderTabVisibilityChips==='function') _renderTabVisibilityChips();
|
||||
}catch(err){
|
||||
if(statusEl) statusEl.textContent='Dashboard link settings failed to save.';
|
||||
else if(typeof showToast==='function') showToast('Dashboard link settings failed to save.');
|
||||
try{await loadDashboardSettings();}catch(_){}
|
||||
if(opts.raiseOnError) throw err;
|
||||
}
|
||||
}
|
||||
function openHermesDashboard(event){
|
||||
|
|
|
|||
|
|
@ -1,10 +1,333 @@
|
|||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
import re
|
||||
import pytest
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
UI_PATH = REPO / "static" / "ui.js"
|
||||
PANELS_PATH = REPO / "static" / "panels.js"
|
||||
NODE = shutil.which("node")
|
||||
requires_node = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
|
||||
_DASHBOARD_LINK_DRIVER = textwrap.dedent(
|
||||
"""\
|
||||
const fs = require('fs');
|
||||
|
||||
function extractFn(src, name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
let start = -1;
|
||||
for (const marker of markers) {
|
||||
start = src.indexOf(marker);
|
||||
if (start >= 0) break;
|
||||
}
|
||||
if (start < 0) throw new Error(`${name}() not found`);
|
||||
let i = src.indexOf('{', start);
|
||||
let depth = 0;
|
||||
let inString = null;
|
||||
let escaped = false;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
for (; i < src.length; i++) {
|
||||
const ch = src[i];
|
||||
const nxt = src[i + 1] || '';
|
||||
if (inLineComment) {
|
||||
if (ch === '\\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (inBlockComment) {
|
||||
if (ch === '*' && nxt === '/') inBlockComment = false;
|
||||
continue;
|
||||
}
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (ch === '\\\\') {
|
||||
escaped = true;
|
||||
} else if (ch === inString) {
|
||||
inString = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && nxt === '/') { inLineComment = true; continue; }
|
||||
if (ch === '/' && nxt === '*') { inBlockComment = true; continue; }
|
||||
if (ch === '\\'' || ch === '\"' || ch === '`') { inString = ch; continue; }
|
||||
if (ch === '{') depth += 1;
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(`could not extract ${name}`);
|
||||
}
|
||||
|
||||
function makeEl() {
|
||||
return {
|
||||
children: [],
|
||||
style: {},
|
||||
classList: {
|
||||
_set: new Set(),
|
||||
add(c){this._set.add(c);},
|
||||
remove(c){this._set.delete(c);},
|
||||
toggle(c, on){const want = on === undefined ? !this._set.has(c) : Boolean(on); if (want) this._set.add(c); else this._set.delete(c);},
|
||||
contains(c){return this._set.has(c);},
|
||||
},
|
||||
dataset: {},
|
||||
_attrs: {},
|
||||
textContent: '',
|
||||
datasetUrl: '',
|
||||
setAttribute(name, value) {
|
||||
this._attrs[name] = String(value);
|
||||
if (name === 'data-dashboard-url') this.datasetUrl = String(value);
|
||||
},
|
||||
getAttribute(name) { return Object.prototype.hasOwnProperty.call(this._attrs, name) ? this._attrs[name] : null; },
|
||||
hasAttribute(name) { return Object.prototype.hasOwnProperty.call(this._attrs, name); },
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
};
|
||||
}
|
||||
|
||||
function makeButton(id) {
|
||||
const btn = makeEl();
|
||||
btn.id = id;
|
||||
btn._classes = btn.classList._set;
|
||||
if (id === 'dashboardRailBtn' || id === 'dashboardMobileBtn') {
|
||||
btn.setAttribute('data-dashboard-link', '');
|
||||
btn.setAttribute('aria-label', 'Dashboard');
|
||||
}
|
||||
return btn;
|
||||
}
|
||||
|
||||
const action = process.argv[2] || 'load';
|
||||
const mode = process.argv[3] || 'auto';
|
||||
const url = process.argv[4] || '';
|
||||
const uiSrc = fs.readFileSync(process.argv[5], 'utf8');
|
||||
const panelsSrc = fs.readFileSync(process.argv[6], 'utf8');
|
||||
|
||||
const modeEl = makeButton('settingsDashboardMode');
|
||||
const urlEl = makeButton('settingsDashboardUrl');
|
||||
const statusEl = makeButton('settingsDashboardStatus');
|
||||
modeEl.value = mode;
|
||||
urlEl.value = url;
|
||||
const railBtn = makeButton('dashboardRailBtn');
|
||||
const mobileBtn = makeButton('dashboardMobileBtn');
|
||||
const buttons = [railBtn, mobileBtn];
|
||||
const result = { calls: [], renderCalls: 0, statusCalls: 0, buttonStates: [] };
|
||||
let delayedConfigResolve = null;
|
||||
const delayedConfigValue = { enabled: 'never', url: 'http://stale.local:1234' };
|
||||
let delayedConfigUsed = false;
|
||||
|
||||
global._dashboardLastNonNeverMode = 'auto';
|
||||
global._dashboardStatusCache = null;
|
||||
global._dashboardStatusFetchedAt = 0;
|
||||
global._dashboardSettingsLoadSeq = 0;
|
||||
global._dashboardSettingsWriteSeq = 0;
|
||||
global.window = { location: { hostname: '127.0.0.1' } };
|
||||
global.document = {
|
||||
createElement: () => makeEl(),
|
||||
querySelectorAll: (sel) => {
|
||||
if (sel === '[data-dashboard-link]') return buttons;
|
||||
return [];
|
||||
},
|
||||
querySelector: () => null,
|
||||
addEventListener: () => {},
|
||||
};
|
||||
global.$ = (id) => {
|
||||
if (id === 'settingsDashboardMode') return modeEl;
|
||||
if (id === 'settingsDashboardUrl') return urlEl;
|
||||
if (id === 'settingsDashboardStatus') return statusEl;
|
||||
return null;
|
||||
};
|
||||
|
||||
global.t = (key) => {
|
||||
if (key === 'tab_dashboard') return 'Dashboard';
|
||||
if (key === 'dashboard_loopback_warning') return 'Loopback';
|
||||
return key;
|
||||
};
|
||||
global._dashboardIsBrowserLoopback = () => false;
|
||||
|
||||
global.api = (url, opts = {}) => {
|
||||
result.calls.push({ url: String(url), method: (opts.method || 'GET').toUpperCase(), body: opts.body || '', timeoutToast: !!(opts.timeoutToast) });
|
||||
if (String(url) === '/api/dashboard/config') {
|
||||
if (
|
||||
(opts.method || 'GET').toUpperCase() === 'GET' &&
|
||||
(action === 'stale-load' || action === 'failed-save-stale-load') &&
|
||||
!delayedConfigUsed
|
||||
) {
|
||||
delayedConfigUsed = true;
|
||||
return new Promise((resolve) => {
|
||||
delayedConfigResolve = () => resolve(delayedConfigValue);
|
||||
});
|
||||
}
|
||||
if ((opts.method || 'GET').toUpperCase() === 'GET' && action === 'failed-save-stale-load') {
|
||||
return Promise.resolve(delayedConfigValue);
|
||||
}
|
||||
const payload = opts.body ? JSON.parse(opts.body) : {};
|
||||
if ((opts.method || 'GET').toUpperCase() === 'POST' && action === 'failed-save-stale-load') {
|
||||
return Promise.reject(new Error('save failed'));
|
||||
}
|
||||
const enabled = payload.enabled || modeEl.value || 'auto';
|
||||
const configuredUrl = payload.url || urlEl.value || '';
|
||||
return Promise.resolve({ enabled, url: configuredUrl });
|
||||
}
|
||||
if (String(url) === '/api/dashboard/status') {
|
||||
result.statusCalls += 1;
|
||||
return Promise.resolve({
|
||||
running: modeEl.value !== 'never',
|
||||
browser_url: modeEl.value === 'never' ? '' : 'http://127.0.0.1:1234',
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ running: false });
|
||||
};
|
||||
global._renderTabVisibilityChips = () => {
|
||||
result.renderCalls += 1;
|
||||
result.lastRenderMode = modeEl.value;
|
||||
};
|
||||
|
||||
for (const name of ['_normalizeDashboardEnabledMode','_setDashboardModeForChip','_getDashboardChipRestoreMode']) {
|
||||
eval(extractFn(uiSrc, name));
|
||||
}
|
||||
for (const name of ['_dashboardBrowserUrl', '_applyDashboardStatus', 'refreshDashboardStatus', 'loadDashboardSettings', 'saveDashboardSettings']) {
|
||||
let src = extractFn(uiSrc, name);
|
||||
if(name === 'saveDashboardSettings'){
|
||||
src = src.replace(
|
||||
"if(typeof _renderTabVisibilityChips==='function') _renderTabVisibilityChips();",
|
||||
"if(typeof globalThis._renderTabVisibilityChips==='function') globalThis._renderTabVisibilityChips();"
|
||||
);
|
||||
}
|
||||
eval(src);
|
||||
}
|
||||
for (const name of ['_dashboardPanelMode', '_toggleDashboardVisibilityChip']) {
|
||||
eval(extractFn(panelsSrc, name));
|
||||
}
|
||||
|
||||
function recordButtons() {
|
||||
result.buttonStates = buttons.map((btn) => ({
|
||||
id: btn.id,
|
||||
classes: Array.from(btn.classList._set || []),
|
||||
display: btn.style.display || '',
|
||||
dashboardUrl: btn._attrs['data-dashboard-url'] || '',
|
||||
tooltip: btn._attrs['data-tooltip'] || '',
|
||||
}));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (action === 'load') {
|
||||
await loadDashboardSettings();
|
||||
recordButtons();
|
||||
console.log(JSON.stringify({
|
||||
mode: modeEl.value,
|
||||
url: urlEl.value,
|
||||
renderCalls: result.renderCalls,
|
||||
lastRenderMode: result.lastRenderMode || '',
|
||||
calls: result.calls,
|
||||
buttonStates: result.buttonStates,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'failed-save-stale-load') {
|
||||
const loadPromise = loadDashboardSettings();
|
||||
modeEl.value = 'always';
|
||||
urlEl.value = 'http://fresh.local:4321';
|
||||
await saveDashboardSettings();
|
||||
if (!delayedConfigResolve) throw new Error('delayed config read was not started');
|
||||
delayedConfigResolve();
|
||||
await loadPromise;
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
recordButtons();
|
||||
console.log(JSON.stringify({
|
||||
mode: modeEl.value,
|
||||
url: urlEl.value,
|
||||
renderCalls: result.renderCalls,
|
||||
lastRenderMode: result.lastRenderMode || '',
|
||||
calls: result.calls,
|
||||
statusCalls: result.statusCalls,
|
||||
buttonStates: result.buttonStates,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'stale-load') {
|
||||
const loadPromise = loadDashboardSettings();
|
||||
modeEl.value = 'always';
|
||||
urlEl.value = 'http://fresh.local:4321';
|
||||
await saveDashboardSettings();
|
||||
if (!delayedConfigResolve) throw new Error('delayed config read was not started');
|
||||
delayedConfigResolve();
|
||||
await loadPromise;
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
recordButtons();
|
||||
console.log(JSON.stringify({
|
||||
mode: modeEl.value,
|
||||
url: urlEl.value,
|
||||
renderCalls: result.renderCalls,
|
||||
lastRenderMode: result.lastRenderMode || '',
|
||||
calls: result.calls,
|
||||
statusCalls: result.statusCalls,
|
||||
buttonStates: result.buttonStates,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'chip-toggle') {
|
||||
_toggleDashboardVisibilityChip();
|
||||
} else {
|
||||
await saveDashboardSettings();
|
||||
}
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
recordButtons();
|
||||
console.log(JSON.stringify({
|
||||
mode: modeEl.value,
|
||||
url: urlEl.value,
|
||||
renderCalls: result.renderCalls,
|
||||
lastRenderMode: result.lastRenderMode || '',
|
||||
calls: result.calls,
|
||||
statusCalls: result.statusCalls,
|
||||
buttonStates: result.buttonStates,
|
||||
}));
|
||||
})().catch((err) => { console.error(err); process.exit(1); });
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _run_dashboard_link_driver(action: str, mode: str = 'auto', url: str = '') -> dict:
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as f:
|
||||
f.write(_DASHBOARD_LINK_DRIVER)
|
||||
driver = f.name
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
NODE,
|
||||
driver,
|
||||
action,
|
||||
mode,
|
||||
url,
|
||||
str(UI_PATH),
|
||||
str(PANELS_PATH),
|
||||
],
|
||||
cwd=REPO,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=2.0,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"node harness failed: {result.stderr or result.stdout}")
|
||||
return json.loads(result.stdout.strip())
|
||||
finally:
|
||||
try:
|
||||
pathlib.Path(driver).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def test_dashboard_nav_buttons_are_hidden_by_default_and_subpath_safe():
|
||||
|
|
@ -95,3 +418,77 @@ def test_desktop_dashboard_link_button_stays_in_desktop_nav():
|
|||
assert mobile_nav_match is not None
|
||||
mobile_nav = mobile_nav_match.group(0)
|
||||
assert 'id="dashboardMobileBtn"' in mobile_nav
|
||||
|
||||
@requires_node
|
||||
def test_dashboard_dropdown_save_resyncs_chip_state():
|
||||
out = _run_dashboard_link_driver("save", mode="never", url="http://example.local:1234")
|
||||
assert out["mode"] == "never"
|
||||
assert out["url"] == "http://example.local:1234"
|
||||
assert out["lastRenderMode"] == "never"
|
||||
assert out["renderCalls"] == 1
|
||||
assert any(call["url"] == "/api/dashboard/config" and call["method"] == "POST" for call in out["calls"])
|
||||
assert any(call["url"] == "/api/dashboard/status" for call in out["calls"])
|
||||
assert out["statusCalls"] >= 1
|
||||
assert all(
|
||||
"dashboard-link-visible" not in state["classes"]
|
||||
and state["display"] == "none"
|
||||
for state in out["buttonStates"]
|
||||
)
|
||||
|
||||
|
||||
@requires_node
|
||||
def test_dashboard_chip_save_keeps_buttons_refreshed():
|
||||
out = _run_dashboard_link_driver("chip-toggle", mode="never", url="http://example.local:1234")
|
||||
assert out["mode"] == "auto"
|
||||
assert out["url"] == "http://example.local:1234"
|
||||
assert out["renderCalls"] == 1
|
||||
assert out["lastRenderMode"] == "auto"
|
||||
assert any(call["url"] == "/api/dashboard/config" and call["method"] == "POST" for call in out["calls"])
|
||||
assert any(call["url"] == "/api/dashboard/status" for call in out["calls"])
|
||||
assert out["statusCalls"] >= 1
|
||||
assert all(
|
||||
"dashboard-link-visible" in state["classes"]
|
||||
and state["display"] != "none"
|
||||
for state in out["buttonStates"]
|
||||
)
|
||||
|
||||
|
||||
@requires_node
|
||||
def test_stale_dashboard_load_does_not_overwrite_newer_save():
|
||||
out = _run_dashboard_link_driver("stale-load", mode="never", url="http://stale.local:1234")
|
||||
assert out["mode"] == "always"
|
||||
assert out["url"] == "http://fresh.local:4321"
|
||||
assert out["renderCalls"] == 1
|
||||
assert out["lastRenderMode"] == "always"
|
||||
assert [
|
||||
(call["url"], call["method"])
|
||||
for call in out["calls"]
|
||||
] == [
|
||||
("/api/dashboard/config", "GET"),
|
||||
("/api/dashboard/config", "POST"),
|
||||
("/api/dashboard/status", "GET"),
|
||||
]
|
||||
assert out["statusCalls"] == 1
|
||||
assert all(
|
||||
"dashboard-link-visible" in state["classes"]
|
||||
and state["display"] != "none"
|
||||
for state in out["buttonStates"]
|
||||
)
|
||||
|
||||
|
||||
@requires_node
|
||||
def test_failed_dashboard_save_reloads_backend_after_stale_load_is_dropped():
|
||||
out = _run_dashboard_link_driver("failed-save-stale-load", mode="never", url="http://stale.local:1234")
|
||||
assert out["mode"] == "never"
|
||||
assert out["url"] == "http://stale.local:1234"
|
||||
assert out["renderCalls"] == 1
|
||||
assert out["lastRenderMode"] == "never"
|
||||
assert [
|
||||
(call["url"], call["method"])
|
||||
for call in out["calls"]
|
||||
] == [
|
||||
("/api/dashboard/config", "GET"),
|
||||
("/api/dashboard/config", "POST"),
|
||||
("/api/dashboard/config", "GET"),
|
||||
]
|
||||
assert out["statusCalls"] == 0
|
||||
|
|
|
|||
|
|
@ -4,16 +4,302 @@ Covers backend validation round-trip, frontend static contracts,
|
|||
i18n coverage, and the key integration points that have broken before.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
PANELS_PATH = ROOT / "static" / "panels.js"
|
||||
UI_PATH = ROOT / "static" / "ui.js"
|
||||
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
requires_node = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
|
||||
_PANELS_DASHBOARD_DRIVER = textwrap.dedent(
|
||||
"""\
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function extractFn(src, name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
let start = -1;
|
||||
for (const marker of markers) {
|
||||
start = src.indexOf(marker);
|
||||
if (start >= 0) break;
|
||||
}
|
||||
if (start < 0) throw new Error(`${name}() not found`);
|
||||
let i = src.indexOf('{', start);
|
||||
let depth = 0;
|
||||
let inString = null;
|
||||
let escaped = false;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
for (; i < src.length; i++) {
|
||||
const ch = src[i];
|
||||
const nxt = src[i + 1] || '';
|
||||
if (inLineComment) {
|
||||
if (ch === '\\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (inBlockComment) {
|
||||
if (ch === '*' && nxt === '/') inBlockComment = false;
|
||||
continue;
|
||||
}
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (ch === '\\\\') {
|
||||
escaped = true;
|
||||
} else if (ch === inString) {
|
||||
inString = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && nxt === '/') { inLineComment = true; continue; }
|
||||
if (ch === '/' && nxt === '*') { inBlockComment = true; continue; }
|
||||
if (ch === '\\'' || ch === '\"' || ch === '`') { inString = ch; continue; }
|
||||
if (ch === '{') depth += 1;
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(`could not extract ${name}`);
|
||||
}
|
||||
|
||||
function makeEl() {
|
||||
return {
|
||||
children: [],
|
||||
style: {},
|
||||
classList: {
|
||||
_set: new Set(),
|
||||
add(c) { this._set.add(c); },
|
||||
remove(c) { this._set.delete(c); },
|
||||
toggle(c, on) {
|
||||
const want = on === undefined ? !this._set.has(c) : Boolean(on);
|
||||
if (want) this._set.add(c); else this._set.delete(c);
|
||||
},
|
||||
contains(c) { return this._set.has(c); },
|
||||
},
|
||||
dataset: {},
|
||||
_attrs: {},
|
||||
textContent: '',
|
||||
setAttribute(name, value) {
|
||||
this._attrs[name] = String(value);
|
||||
if (name === 'data-tab-panel') this.dataset.tabPanel = String(value);
|
||||
},
|
||||
getAttribute(name) {
|
||||
return Object.prototype.hasOwnProperty.call(this._attrs, name)
|
||||
? this._attrs[name]
|
||||
: null;
|
||||
},
|
||||
hasAttribute(name) {
|
||||
return Object.prototype.hasOwnProperty.call(this._attrs, name);
|
||||
},
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
const action = process.argv[2] || 'render';
|
||||
const mode = process.argv[3] || 'auto';
|
||||
const priorMode = process.argv[4] || '';
|
||||
const forceNoRestore = process.argv[5] === '1';
|
||||
const failSave = process.argv[6] === '1';
|
||||
const panelsSrc = fs.readFileSync(process.argv[7], 'utf8');
|
||||
const uiSrc = fs.readFileSync(process.argv[8], 'utf8');
|
||||
|
||||
const container = makeEl();
|
||||
const modeEl = makeEl();
|
||||
modeEl.id = 'settingsDashboardMode';
|
||||
modeEl.value = mode;
|
||||
const urlEl = makeEl();
|
||||
urlEl.id = 'settingsDashboardUrl';
|
||||
urlEl.value = '';
|
||||
|
||||
const registry = Object.create(null);
|
||||
registry.tabVisibilityChips = container;
|
||||
registry.settingsDashboardMode = modeEl;
|
||||
registry.settingsDashboardUrl = urlEl;
|
||||
|
||||
let apiCalls = [];
|
||||
let renderCalls = 0;
|
||||
let hiddenCalls = 0;
|
||||
let tabOrderCalls = 0;
|
||||
|
||||
global._dashboardLastNonNeverMode = 'auto';
|
||||
global.window = {};
|
||||
global.localStorage = {
|
||||
_store: Object.create(null),
|
||||
getItem(k) {
|
||||
return Object.prototype.hasOwnProperty.call(this._store, k) ? this._store[k] : null;
|
||||
},
|
||||
setItem(k, v) {
|
||||
this._store[k] = String(v);
|
||||
},
|
||||
};
|
||||
global.document = {
|
||||
createElement: () => makeEl(),
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: () => {},
|
||||
};
|
||||
global.$ = (id) => registry[id] || null;
|
||||
global._orderedSidebarPanels = () => [];
|
||||
global._getHiddenTabs = () => [];
|
||||
global._wireTabChipDrag = () => {};
|
||||
global._tabVisibilityDragSuppressUntil = 0;
|
||||
global._ALWAYS_VISIBLE_TABS = new Set(['chat', 'settings']);
|
||||
global.t = (key) => key === 'tab_dashboard' ? 'Hermes Dashboard' : String(key);
|
||||
|
||||
let failNextSave = failSave;
|
||||
global.api = (url, opts = {}) => {
|
||||
apiCalls.push({ url: String(url), method: (opts.method || 'GET').toUpperCase(), body: opts.body || '' });
|
||||
if (String(url) === '/api/dashboard/config' && failNextSave) {
|
||||
failNextSave = false;
|
||||
return Promise.reject(new Error('save failed'));
|
||||
}
|
||||
const payload = opts.body ? JSON.parse(opts.body) : {};
|
||||
return Promise.resolve({ enabled: payload.enabled || 'auto', url: payload.url || '' });
|
||||
};
|
||||
global.saveDashboardSettings = async function (opts = {}) {
|
||||
const payload = { enabled: modeEl.value || 'auto', url: (urlEl.value || '').trim() };
|
||||
try {
|
||||
const saved = await api('/api/dashboard/config', { method: 'POST', body: JSON.stringify(payload) });
|
||||
const normalized = _normalizeDashboardEnabledMode(saved && saved.enabled);
|
||||
modeEl.value = normalized;
|
||||
_setDashboardModeForChip(normalized);
|
||||
return saved;
|
||||
} catch (err) {
|
||||
if (opts.raiseOnError) throw err;
|
||||
}
|
||||
};
|
||||
global._setHiddenTabs = () => { hiddenCalls += 1; };
|
||||
global._setTabOrder = () => { tabOrderCalls += 1; };
|
||||
global._renderTabVisibilityChips = () => { renderCalls += 1; };
|
||||
|
||||
for (const name of ['_normalizeDashboardEnabledMode', '_setDashboardModeForChip', '_getDashboardChipRestoreMode']) {
|
||||
eval(extractFn(uiSrc, name));
|
||||
}
|
||||
for (const name of [
|
||||
'_dashboardPanelMode',
|
||||
'_isDashboardChipOn',
|
||||
'_renderDashboardVisibilityChip',
|
||||
'_renderTabVisibilityChips',
|
||||
'_toggleDashboardVisibilityChip'
|
||||
]) {
|
||||
eval(extractFn(panelsSrc, name));
|
||||
}
|
||||
const realRenderTabVisibilityChips = _renderTabVisibilityChips;
|
||||
_renderTabVisibilityChips = function () {
|
||||
renderCalls += 1;
|
||||
return realRenderTabVisibilityChips();
|
||||
};
|
||||
|
||||
(async () => {
|
||||
if (forceNoRestore) global._dashboardLastNonNeverMode = null;
|
||||
if (priorMode) _setDashboardModeForChip(priorMode);
|
||||
|
||||
if (action === 'render') {
|
||||
_renderTabVisibilityChips();
|
||||
const chip = container.children.find((node) => node.getAttribute('data-tab-panel') === '__hermes_dashboard__');
|
||||
console.log(JSON.stringify({
|
||||
chipCount: container.children.length,
|
||||
hasChip: !!chip,
|
||||
chipText: chip && chip.textContent,
|
||||
chipAriaChecked: chip && chip.getAttribute('aria-checked'),
|
||||
chipIsOff: chip && chip.classList.contains('chip-off'),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
_toggleDashboardVisibilityChip();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const firstMode = modeEl.value;
|
||||
|
||||
if (action === 'toggle-fail') {
|
||||
console.log(JSON.stringify({
|
||||
firstMode,
|
||||
dashboardLastNonNeverMode: global._dashboardLastNonNeverMode,
|
||||
apiCalls,
|
||||
hiddenCalls,
|
||||
tabOrderCalls,
|
||||
renderCalls,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
_toggleDashboardVisibilityChip();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const secondMode = modeEl.value;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
firstMode,
|
||||
secondMode,
|
||||
dashboardLastNonNeverMode: global._dashboardLastNonNeverMode,
|
||||
apiCalls,
|
||||
hiddenCalls,
|
||||
tabOrderCalls,
|
||||
renderCalls,
|
||||
}));
|
||||
})().catch((err) => { console.error(err); process.exit(1); });
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _run_panels_driver(
|
||||
action: str,
|
||||
mode: str = 'auto',
|
||||
prior_mode: str = '',
|
||||
force_no_restore: bool = False,
|
||||
fail_save: bool = False,
|
||||
) -> dict:
|
||||
"""Run the dashboard-visibility chip helpers with a DOM shim."""
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as f:
|
||||
f.write(_PANELS_DASHBOARD_DRIVER)
|
||||
driver = f.name
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
NODE,
|
||||
driver,
|
||||
action,
|
||||
mode,
|
||||
prior_mode,
|
||||
"1" if force_no_restore else "0",
|
||||
"1" if fail_save else "0",
|
||||
str(PANELS_PATH),
|
||||
str(UI_PATH),
|
||||
],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=2.0,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"node harness failed: {result.stderr or result.stdout}")
|
||||
return json.loads(result.stdout.strip())
|
||||
finally:
|
||||
try:
|
||||
Path(driver).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def test_backend_round_trip_and_validation(monkeypatch, tmp_path):
|
||||
"""hidden_tabs defaults to [], saves/reloads, rejects non-list, filters empty strings."""
|
||||
|
|
@ -180,6 +466,66 @@ def test_chip_a11y_uses_switch_role_with_aria_checked():
|
|||
"chip needs drag-over style for mouse reorder target feedback"
|
||||
|
||||
|
||||
@requires_node
|
||||
def test_dashboard_chip_renders_in_tab_visibility_grid():
|
||||
"""Behavioral verification for render state and placement in the chip grid."""
|
||||
for mode in ("auto", "always", "never"):
|
||||
out = _run_panels_driver("render", mode=mode)
|
||||
assert out["hasChip"] is True, out
|
||||
if mode == "never":
|
||||
assert out["chipIsOff"] is True, out
|
||||
assert out["chipAriaChecked"] == "false", out
|
||||
else:
|
||||
assert out["chipIsOff"] is False, out
|
||||
assert out["chipAriaChecked"] == "true", out
|
||||
assert out["chipText"] == "Hermes Dashboard", out
|
||||
assert out["chipCount"] >= 1, out
|
||||
|
||||
|
||||
@requires_node
|
||||
def test_dashboard_chip_off_sets_never_and_chip_on_restores_prior_mode():
|
||||
"""Chip toggling must save 'never' and restore non-never mode on the next toggle."""
|
||||
out = _run_panels_driver("toggle", mode="always", prior_mode="always")
|
||||
assert out["firstMode"] == "never", out
|
||||
assert out["secondMode"] == "always", out
|
||||
assert len([call for call in out["apiCalls"] if call["url"] == "/api/dashboard/config"]) == 2, out
|
||||
assert out["hiddenCalls"] == 0, out
|
||||
assert out["tabOrderCalls"] == 0, out
|
||||
assert out["dashboardLastNonNeverMode"] == "always", out
|
||||
|
||||
|
||||
@requires_node
|
||||
def test_dashboard_chip_toggle_does_not_mutate_hidden_tabs_or_tab_order():
|
||||
"""Dashboard chip toggles stay on the dashboard config path, never the sidebar-tab payload."""
|
||||
out = _run_panels_driver("toggle", mode="auto", prior_mode="auto")
|
||||
assert out["firstMode"] == "never", out
|
||||
assert len([call for call in out["apiCalls"] if call["url"] == "/api/dashboard/config"]) == 2, out
|
||||
assert out["hiddenCalls"] == 0, out
|
||||
assert out["tabOrderCalls"] == 0, out
|
||||
|
||||
|
||||
@requires_node
|
||||
def test_dashboard_chip_on_defaults_to_auto_without_prior_mode():
|
||||
"""Missing restore state should fall back to 'auto' before returning chip-on."""
|
||||
out = _run_panels_driver("toggle", mode="never", force_no_restore=True)
|
||||
assert out["firstMode"] == "auto", out
|
||||
assert out["secondMode"] == "never", out
|
||||
assert out["hiddenCalls"] == 0, out
|
||||
assert out["tabOrderCalls"] == 0, out
|
||||
|
||||
|
||||
@requires_node
|
||||
def test_dashboard_chip_failed_save_restores_previous_mode():
|
||||
"""A failed chip save must roll the dropdown and chip state back to the prior mode."""
|
||||
out = _run_panels_driver("toggle-fail", mode="auto", prior_mode="auto", fail_save=True)
|
||||
assert out["firstMode"] == "auto", out
|
||||
assert len([call for call in out["apiCalls"] if call["url"] == "/api/dashboard/config"]) == 1, out
|
||||
assert out["hiddenCalls"] == 0, out
|
||||
assert out["tabOrderCalls"] == 0, out
|
||||
assert out["renderCalls"] == 1, out
|
||||
assert out["dashboardLastNonNeverMode"] == "auto", out
|
||||
|
||||
|
||||
def test_tab_order_excludes_always_visible_tabs(monkeypatch, tmp_path):
|
||||
"""Server-side tab_order validation mirrors hidden_tabs: chat and settings
|
||||
remain fixed, so a tampered payload must not persist them in custom order."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue