mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
c9bc21f394
- Patch tests/test_issue2762_state_sync_profile_kwarg.py::_read_session
helper to query the real state.db schema (sessions.id PRIMARY KEY,
not sessions.session_id). Was always broken — the test never matched
any actual schema. Fix: SELECT id AS session_id + WHERE id = ?
- Patch tests/test_session_metadata_fast_path.py::test_failed_boot_model_catalog_prime_is_retryable
to accept both populateModelDropdown() and populateModelDropdown({preferProfileDefaultOnFreshBoot:true})
signatures (sibling-collision with #2726).
- Patch tests/test_model_default_boot_precedence.py::test_boot_model_dropdown_explicitly_requests_profile_default_precedence
to accept either the original allowBootSavedModelOverride variable
name OR the post-#2716-cherry-pick stateToApply equivalent
(!window._defaultModel?savedState:null gate).
- Stamp CHANGELOG for v0.51.130 (Release DB).
321 lines
12 KiB
Python
321 lines
12 KiB
Python
"""
|
|
Regression coverage for model selector drift on fresh browser boot.
|
|
|
|
A stale browser-persisted model (localStorage) must not suppress the configured
|
|
profile/server default on page load. Restored sessions may still apply their own
|
|
session model later through loadSession(). The boot fix must not make browser
|
|
model-state writes pointless or let later model-list refreshes reset a live
|
|
in-page selection.
|
|
"""
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
|
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
_DRIVER_SRC = r"""
|
|
const fs = require('fs');
|
|
const ui = fs.readFileSync(process.argv[2], 'utf8');
|
|
|
|
function extractFunc(name) {
|
|
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\(');
|
|
const start = ui.search(re);
|
|
if (start < 0) throw new Error(name + ' not found');
|
|
let openParen = ui.indexOf('(', start);
|
|
let i = openParen + 1;
|
|
let parenDepth = 1;
|
|
while (parenDepth > 0 && i < ui.length) {
|
|
if (ui[i] === '(') parenDepth++;
|
|
else if (ui[i] === ')') parenDepth--;
|
|
i++;
|
|
}
|
|
i = ui.indexOf('{', i);
|
|
let depth = 1;
|
|
i++;
|
|
while (depth > 0 && i < ui.length) {
|
|
if (ui[i] === '{') depth++;
|
|
else if (ui[i] === '}') depth--;
|
|
i++;
|
|
}
|
|
return ui.slice(start, i);
|
|
}
|
|
|
|
const calls = {syncModelChip: 0, renderModelDropdown: 0, positionModelDropdown: 0, liveFetches: []};
|
|
let modelSelect;
|
|
let apiModels;
|
|
|
|
function makeSelect(options, initialValue) {
|
|
const sel = {id: 'modelSelect', options: [], selectedIndex: -1, selectedOptions: []};
|
|
Object.defineProperty(sel, 'value', {
|
|
get() { return this._value || ''; },
|
|
set(v) {
|
|
this._value = v;
|
|
const idx = this.options.findIndex(o => o.value === v);
|
|
this.selectedIndex = idx;
|
|
this.selectedOptions = idx >= 0 ? [this.options[idx]] : [];
|
|
}
|
|
});
|
|
Object.defineProperty(sel, 'innerHTML', {
|
|
get() { return ''; },
|
|
set(_v) {
|
|
this.options = [];
|
|
this.value = '';
|
|
}
|
|
});
|
|
sel.querySelector = function(_selector) { return this.options[0] || null; };
|
|
sel.appendChild = function(node) {
|
|
const incoming = node && node.tagName === 'OPTGROUP' ? node.children : [node];
|
|
for (const opt of incoming || []) {
|
|
opt.parentElement = opt.parentElement || node;
|
|
this.options.push(opt);
|
|
if (this.selectedIndex < 0) this.value = opt.value;
|
|
else if (this._value === opt.value) this.value = opt.value;
|
|
}
|
|
};
|
|
for (const item of options || []) {
|
|
const group = {tagName: 'OPTGROUP', dataset: {provider: item.provider || ''}, children: []};
|
|
const opt = {tagName: 'OPTION', value: item.value, textContent: item.label || item.value, title: '', dataset: {}, parentElement: group};
|
|
group.children.push(opt);
|
|
sel.appendChild(group);
|
|
}
|
|
sel.value = initialValue || '';
|
|
return sel;
|
|
}
|
|
|
|
function $(id) {
|
|
if (id === 'modelSelect') return modelSelect;
|
|
if (id === 'composerModelDropdown') return {classList: {contains(){ return false; }}};
|
|
return null;
|
|
}
|
|
function t(key) { return key; }
|
|
function getModelLabel(v) { return v; }
|
|
function syncModelChip() { calls.syncModelChip++; }
|
|
function renderModelDropdown() { calls.renderModelDropdown++; }
|
|
function _positionModelDropdown() { calls.positionModelDropdown++; }
|
|
function _redirectIfUnauth() { return false; }
|
|
function _fetchLiveModels(provider, _sel) { calls.liveFetches.push(provider); }
|
|
|
|
const document = {
|
|
baseURI: 'http://127.0.0.1/hermes/',
|
|
createElement(tag) {
|
|
const upper = tag.toUpperCase();
|
|
if (upper === 'OPTGROUP') {
|
|
return {
|
|
tagName: 'OPTGROUP',
|
|
label: '',
|
|
dataset: {},
|
|
children: [],
|
|
appendChild(opt) { opt.parentElement = this; this.children.push(opt); },
|
|
};
|
|
}
|
|
return {tagName: upper, value: '', textContent: '', title: '', dataset: {}, parentElement: null};
|
|
},
|
|
};
|
|
const localStorage = {getItem(){return null;}, setItem(){}, removeItem(){}};
|
|
const window = {_defaultModel: null, _activeProvider: null, _configuredModelBadges: {}};
|
|
let _dynamicModelLabels = {};
|
|
let _liveModelFetchPending = new Set();
|
|
let _liveModelCache = {};
|
|
|
|
for (const name of [
|
|
'_getOptionProviderId', '_providerFromModelValue', '_modelStateForSelect',
|
|
'_captureModelDropdownSelection', '_findModelInDropdown', '_refreshOpenModelDropdown',
|
|
'_applyModelToDropdown', '_reconcileModelDropdownSelection', 'populateModelDropdown'
|
|
]) {
|
|
eval(extractFunc(name));
|
|
}
|
|
|
|
const args = JSON.parse(process.argv[3]);
|
|
apiModels = args.apiModels;
|
|
modelSelect = makeSelect(args.initialOptions, args.initialValue);
|
|
var S = {session: args.session || null};
|
|
|
|
fetch = async function(url) {
|
|
const href = String(url);
|
|
if (href.includes('api/models')) {
|
|
return {json: async () => apiModels};
|
|
}
|
|
throw new Error('unexpected fetch ' + href);
|
|
};
|
|
|
|
populateModelDropdown(args.opts || {}).then(() => {
|
|
process.stdout.write(JSON.stringify({
|
|
selectValue: modelSelect.value,
|
|
selectedProvider: modelSelect.selectedOptions[0] ? _getOptionProviderId(modelSelect.selectedOptions[0]) : null,
|
|
defaultModel: window._defaultModel,
|
|
activeProvider: window._activeProvider,
|
|
calls,
|
|
}));
|
|
}).catch(err => {
|
|
console.error(err && err.stack || err);
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def populate_driver_path(tmp_path_factory):
|
|
p = tmp_path_factory.mktemp("model_default_driver") / "driver.js"
|
|
p.write_text(_DRIVER_SRC, encoding="utf-8")
|
|
return str(p)
|
|
|
|
|
|
def test_boot_settings_applies_default_without_deleting_browser_model_state():
|
|
snippet = _boot_default_apply_snippet()
|
|
assert "Fresh page boot must prefer the profile/server default" in snippet
|
|
assert "if(sel&&typeof _applyModelToDropdown==='function')" in snippet
|
|
assert "if(sel&&!savedState&&typeof _applyModelToDropdown==='function')" not in BOOT_JS
|
|
assert "_clearPersistedModelState" not in snippet
|
|
assert "localStorage.removeItem('hermes-webui-model')" not in snippet
|
|
assert "localStorage.removeItem('hermes-webui-model-state')" not in snippet
|
|
|
|
|
|
def test_boot_model_dropdown_explicitly_requests_profile_default_precedence():
|
|
assert "populateModelDropdown({preferProfileDefaultOnFreshBoot:true})" in BOOT_JS
|
|
# #2726 invariant: boot path must keep profile/server default ahead of stale
|
|
# browser-persisted state when a default exists. Post-#2716 cherry-pick onto
|
|
# post-stage-batch11 master uses `stateToApply` pattern rather than the
|
|
# original `allowBootSavedModelOverride` variable name. Semantics preserved —
|
|
# check for the actual gate predicate that's equivalent.
|
|
assert (
|
|
"const allowBootSavedModelOverride=!window._defaultModel" in BOOT_JS
|
|
or "!window._defaultModel?savedState:null" in BOOT_JS
|
|
), "boot.js missing the !window._defaultModel gate for saved-state override"
|
|
|
|
|
|
def test_populate_model_dropdown_reconciles_selection_after_rebuild():
|
|
assert "const previousSelection=_captureModelDropdownSelection(sel);" in UI_JS
|
|
assert "_reconcileModelDropdownSelection(sel,data,previousSelection,opts);" in UI_JS
|
|
snippet = _reconcile_selection_snippet()
|
|
assert "preferProfileDefaultOnFreshBoot" in snippet
|
|
assert "return _applyModelToDropdown(data.default_model,sel,data.active_provider||null);" in snippet
|
|
assert "return _applyModelToDropdown(activeSession.model,sel,activeSession.model_provider||null);" in snippet
|
|
assert "return _applyModelToDropdown(previousState.model,sel,previousState.model_provider||null);" in snippet
|
|
assert "_readPersistedModelState()" not in snippet
|
|
assert "localStorage.getItem('hermes-webui-model')" not in snippet
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
def test_non_boot_model_refresh_preserves_current_in_page_selection(populate_driver_path):
|
|
got = _run_populate_driver(
|
|
populate_driver_path,
|
|
initial_value="@expensive:gpt-5.5",
|
|
opts={},
|
|
session=None,
|
|
)
|
|
|
|
assert got["selectValue"] == "@expensive:gpt-5.5"
|
|
assert got["selectedProvider"] == "expensive"
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
def test_boot_model_refresh_prefers_profile_default_over_stale_selection(populate_driver_path):
|
|
got = _run_populate_driver(
|
|
populate_driver_path,
|
|
initial_value="@expensive:gpt-5.5",
|
|
opts={"preferProfileDefaultOnFreshBoot": True},
|
|
session=None,
|
|
)
|
|
|
|
assert got["selectValue"] == "@safe:gpt-4o-mini"
|
|
assert got["selectedProvider"] == "safe"
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
def test_session_model_wins_over_boot_default_and_previous_selection(populate_driver_path):
|
|
got = _run_populate_driver(
|
|
populate_driver_path,
|
|
initial_value="@expensive:gpt-5.5",
|
|
opts={"preferProfileDefaultOnFreshBoot": True},
|
|
session={"model": "@work:glm-5.1", "model_provider": "work"},
|
|
)
|
|
|
|
assert got["selectValue"] == "@work:glm-5.1"
|
|
assert got["selectedProvider"] == "work"
|
|
|
|
|
|
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
def test_non_boot_refresh_does_not_reapply_default_when_previous_model_disappears(populate_driver_path):
|
|
got = _run_populate_driver(
|
|
populate_driver_path,
|
|
initial_value="@removed:gpt-old",
|
|
opts={},
|
|
session=None,
|
|
api_groups=[
|
|
{"provider": "Safe", "provider_id": "safe", "models": [{"id": "@safe:gpt-4o-mini", "label": "GPT-4o mini"}]},
|
|
],
|
|
initial_options=[
|
|
{"provider": "removed", "value": "@removed:gpt-old", "label": "Old"},
|
|
],
|
|
)
|
|
|
|
# The old value is gone, so the browser/select fallback can land on the
|
|
# refreshed first option. The important invariant is that the profile default
|
|
# is not reapplied as a special policy outside fresh boot.
|
|
assert got["selectValue"] == "@safe:gpt-4o-mini"
|
|
assert got["selectedProvider"] == "safe"
|
|
|
|
|
|
def _run_populate_driver(
|
|
driver_path: str,
|
|
*,
|
|
initial_value: str,
|
|
opts: dict,
|
|
session: dict | None,
|
|
api_groups: list[dict] | None = None,
|
|
initial_options: list[dict] | None = None,
|
|
):
|
|
groups = api_groups or [
|
|
{"provider": "Safe", "provider_id": "safe", "models": [{"id": "@safe:gpt-4o-mini", "label": "GPT-4o mini"}]},
|
|
{"provider": "Expensive", "provider_id": "expensive", "models": [{"id": "@expensive:gpt-5.5", "label": "GPT-5.5"}]},
|
|
{"provider": "Work", "provider_id": "work", "models": [{"id": "@work:glm-5.1", "label": "GLM-5.1"}]},
|
|
]
|
|
payload = {
|
|
"initialValue": initial_value,
|
|
"initialOptions": initial_options
|
|
or [
|
|
{"provider": "expensive", "value": "@expensive:gpt-5.5", "label": "GPT-5.5"},
|
|
{"provider": "safe", "value": "@safe:gpt-4o-mini", "label": "GPT-4o mini"},
|
|
{"provider": "work", "value": "@work:glm-5.1", "label": "GLM-5.1"},
|
|
],
|
|
"apiModels": {
|
|
"active_provider": "safe",
|
|
"default_model": "@safe:gpt-4o-mini",
|
|
"configured_model_badges": {},
|
|
"groups": groups,
|
|
},
|
|
"opts": opts,
|
|
"session": session,
|
|
}
|
|
assert NODE is not None
|
|
result = subprocess.run(
|
|
[NODE, driver_path, str(REPO / "static" / "ui.js"), json.dumps(payload)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"node driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}")
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def _boot_default_apply_snippet() -> str:
|
|
marker = "// Fresh page boot must prefer the profile/server default"
|
|
start = BOOT_JS.index(marker)
|
|
return BOOT_JS[start - 120 : start + 700]
|
|
|
|
|
|
def _reconcile_selection_snippet() -> str:
|
|
marker = "function _reconcileModelDropdownSelection"
|
|
start = UI_JS.index(marker)
|
|
end = UI_JS.index("function _providerQualifiedModelValueForSelect", start)
|
|
return UI_JS[start:end]
|