Merge pull request #5432 from nesquena/release/stage-5412
Release — instant profile-dropdown open (#5412) + gate-found brick fix
This commit is contained in:
4 files changed
+409
-13
No files matched your search
@@ -9,6 +9,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The profile dropdown opens instantly instead of stalling on a cold fetch.** On installs with many profiles (or slow profile metadata), clicking the profile chip used to block on a fresh `/api/profiles` request before the menu appeared, so it felt unresponsive for up to a few seconds. The dropdown now opens immediately from a short-lived cache (rendering a lightweight loading shell when there's no cache yet) and refreshes fresh data in the background; a malformed cache row is validated and purged rather than shown, and a single-profile install keeps its shared cache intact. Thanks @ruizanthony. (#5412)
|
||||
- **Hardened the built-in text-to-speech proxy against a DNS-rebinding attack (SSRF).** When you route text-to-speech through an OpenAI-compatible endpoint, the proxy used to validate the target host's address and then resolve it again to connect — a gap an attacker-controlled DNS server could exploit to answer "public" during the check and "internal" at connect time, redirecting the request (with your API key attached) at a machine on your private network. The proxy now resolves the host once, validates every returned address up front (rejecting any private/loopback/link-local/reserved target), and connects only to those already-vetted addresses — while keeping the original hostname for TLS/SNI so certificate validation is unaffected. Multi-address hosts still fail over correctly across their vetted addresses, environment proxies can't bypass the direct dial, and redirects can't carry your token elsewhere. Thanks @rodboev. (#5407, #5291)
|
||||
- **Creating a new conversation right after switching profiles no longer fails with a 404.** After switching profiles, the browser still held the previous profile's session id, and the new-session request ran a cross-profile access guard against that stale id — which 404'd and aborted the whole create, so you couldn't start a fresh chat until you reloaded. The create now proceeds regardless: on a cross-profile previous session it simply skips that session's memory-commit (it belongs to the other profile) and starts the new one. Cross-profile isolation is unchanged — the guard still refuses to read or commit another profile's session. Thanks @nankingjing. (#5423, #5420)
|
||||
- **Remote gateway health checks stop reporting a false failure on authenticated deployments.** In a multi-service deployment where the agent's API server requires a key, the WebUI's `/health/detailed` probe was sent without credentials and came back 401, so the health panel showed the gateway as down when it was fine. The probe now presents the same Bearer token the agent expects (only on `/health/detailed`, never logged, omitted entirely when no key is configured). Thanks @nankingjing. (#5424, #5418)
|
||||
|
||||
+127
-12
@@ -5921,8 +5921,106 @@ async function switchToWorkspace(path,name){
|
||||
|
||||
// ── Profile panel + dropdown ──
|
||||
let _profilesCache = null;
|
||||
let _profileDropdownFetchPromise = null;
|
||||
let _profileDropdownCacheLoadedFromStorage = false;
|
||||
const PROFILE_DROPDOWN_CACHE_KEY = 'hermes-webui-profile-dropdown-cache-v1';
|
||||
const PROFILE_DROPDOWN_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
let _profileSwitchGeneration = 0;
|
||||
let _profileDropdownTrigger = null; // tracks which element triggered the dropdown
|
||||
let _profileDropdownOpenGeneration = 0;
|
||||
|
||||
function _profileDropdownClearStoredCache(){
|
||||
try{localStorage.removeItem(PROFILE_DROPDOWN_CACHE_KEY);}catch(_){}
|
||||
}
|
||||
|
||||
function _profileDropdownDataCacheUsable(data){
|
||||
return !!(
|
||||
data &&
|
||||
Array.isArray(data.profiles) &&
|
||||
data.profiles.length &&
|
||||
data.profiles.every(p=>
|
||||
p &&
|
||||
typeof p.name==='string' &&
|
||||
// Renderer-read fields must be safe types: renderProfileDropdown /
|
||||
// renderProfilesPanel call p.model.split('/') guarded only by truthiness,
|
||||
// so a poisoned cached row like {name:"x", model:{}} would pass a
|
||||
// name-only check yet throw synchronously on dropdown open (bricking
|
||||
// profile switching). Reject rows whose model is a non-string truthy value.
|
||||
(p.model==null || typeof p.model==='string')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function _profileDropdownCacheUsable(data){
|
||||
return !!(_profileDropdownDataCacheUsable(data) && data.single_profile_mode !== true);
|
||||
}
|
||||
|
||||
function _profileDropdownReadStoredCache(){
|
||||
if(_profileDropdownCacheLoadedFromStorage) return _profileDropdownCacheUsable(_profilesCache) ? _profilesCache : null;
|
||||
_profileDropdownCacheLoadedFromStorage = true;
|
||||
try{
|
||||
const raw=localStorage.getItem(PROFILE_DROPDOWN_CACHE_KEY);
|
||||
if(!raw) return null;
|
||||
const parsed=JSON.parse(raw);
|
||||
if(!parsed || typeof parsed.ts!=='number' || !parsed.data) { _profileDropdownClearStoredCache(); return null; }
|
||||
if(Date.now()-parsed.ts>PROFILE_DROPDOWN_CACHE_TTL_MS) { _profileDropdownClearStoredCache(); return null; }
|
||||
if(!_profileDropdownCacheUsable(parsed.data)) { _profileDropdownClearStoredCache(); return null; }
|
||||
_profilesCache = parsed.data;
|
||||
return _profilesCache;
|
||||
}catch(_){_profileDropdownClearStoredCache();return null;}
|
||||
}
|
||||
|
||||
function _profileDropdownWriteStoredCache(data){
|
||||
if(!_profileDropdownCacheUsable(data)) { _profileDropdownClearStoredCache(); return; }
|
||||
try{localStorage.setItem(PROFILE_DROPDOWN_CACHE_KEY, JSON.stringify({ts:Date.now(), data}));}catch(_){}
|
||||
}
|
||||
|
||||
function _profileDropdownBestCachedData(){
|
||||
if(_profileDropdownCacheUsable(_profilesCache)) return _profilesCache;
|
||||
if(_profileDropdownDataCacheUsable(_profilesCache)) return null;
|
||||
_profilesCache = null;
|
||||
return _profileDropdownReadStoredCache();
|
||||
}
|
||||
|
||||
function _profileDropdownFetchFresh(){
|
||||
if(_profileDropdownFetchPromise) return _profileDropdownFetchPromise;
|
||||
_profileDropdownFetchPromise = api('/api/profiles', {timeoutToast:false}).then(data=>{
|
||||
if(_profileDropdownDataCacheUsable(data)) _profilesCache = data;
|
||||
_profileDropdownWriteStoredCache(data);
|
||||
return data;
|
||||
}).finally(()=>{ _profileDropdownFetchPromise = null; });
|
||||
return _profileDropdownFetchPromise;
|
||||
}
|
||||
|
||||
function _warmProfileDropdownCache(){
|
||||
_profileDropdownBestCachedData();
|
||||
_profileDropdownFetchFresh().catch(()=>{});
|
||||
}
|
||||
|
||||
if(typeof window!=='undefined'){
|
||||
window.addEventListener('load',()=>{
|
||||
setTimeout(()=>{
|
||||
if(typeof document==='undefined'||!document.hidden) _warmProfileDropdownCache();
|
||||
},1200);
|
||||
},{once:true});
|
||||
}
|
||||
|
||||
function _renderProfileDropdownLoading(){
|
||||
const dd=$('profileDropdown');
|
||||
if(!dd)return;
|
||||
dd.innerHTML=`<div class="profile-opt profile-opt-loading"><div class="profile-opt-name">${esc(t('loading')||'Loading...')}</div></div>`;
|
||||
}
|
||||
|
||||
function _openProfileDropdownShell(){
|
||||
const dd=$('profileDropdown');
|
||||
if(!dd)return;
|
||||
dd.classList.add('open');
|
||||
_positionProfileDropdown();
|
||||
const chip=$('profileChip');
|
||||
if(chip && _profileDropdownTrigger===chip) chip.classList.add('active');
|
||||
const tbtn=$('titlebarProfileBtn');
|
||||
if(tbtn && _profileDropdownTrigger===tbtn) tbtn.classList.add('active');
|
||||
}
|
||||
|
||||
async function _profileSwitchPanelLoad(){
|
||||
// Cross-profile cron visibility is an active-profile opt-in; never carry it
|
||||
@@ -5983,6 +6081,7 @@ async function loadProfilesPanel() {
|
||||
try {
|
||||
const data = await api('/api/profiles');
|
||||
_profilesCache = data;
|
||||
_profileDropdownWriteStoredCache(data);
|
||||
panel.innerHTML = '';
|
||||
|
||||
// Hide "New profile" button in single profile mode
|
||||
@@ -6022,7 +6121,7 @@ async function loadProfilesPanel() {
|
||||
card.className = 'profile-card';
|
||||
card.dataset.name = p.name;
|
||||
const meta = [];
|
||||
if (p.model) meta.push(p.model.split('/').pop());
|
||||
if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());
|
||||
if (p.provider) meta.push(p.provider);
|
||||
if (p.total_skills && p.total_skills > 0) meta.push(t('profile_skill_count', p.total_skills).replace(String(p.total_skills), `${p.enabled_skills} / ${p.total_skills}`));
|
||||
const gwDot = p.gateway_running
|
||||
@@ -6188,10 +6287,11 @@ async function deleteCurrentProfile(){
|
||||
}
|
||||
|
||||
function renderProfileDropdown(data) {
|
||||
data = data || {};
|
||||
const dd = $('profileDropdown');
|
||||
if (!dd) return;
|
||||
dd.innerHTML = '';
|
||||
const allProfiles = data.profiles || [];
|
||||
const allProfiles = (Array.isArray(data.profiles) ? data.profiles : []).filter(p => p && typeof p.name === 'string');
|
||||
const active = (S.activeProfile && allProfiles.some(p => p.name === S.activeProfile))
|
||||
? S.activeProfile
|
||||
: (data.active || 'default');
|
||||
@@ -6200,7 +6300,7 @@ function renderProfileDropdown(data) {
|
||||
const opt = document.createElement('div');
|
||||
opt.className = 'profile-opt' + (p.name === active ? ' active' : '');
|
||||
const meta = [];
|
||||
if (p.model) meta.push(p.model.split('/').pop());
|
||||
if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());
|
||||
if (p.total_skills && p.total_skills > 0) meta.push(t('profile_skill_count', p.total_skills).replace(String(p.total_skills), `${p.enabled_skills} / ${p.total_skills}`));
|
||||
const gwDot = `<span class="profile-opt-badge ${p.gateway_running ? 'running' : 'stopped'}"></span>`;
|
||||
const checkmark = p.name === active ? ' <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--link)" stroke-width="3" style="vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>' : '';
|
||||
@@ -6235,24 +6335,39 @@ function toggleProfileDropdown(e) {
|
||||
if(typeof closeModelDropdown==='function') closeModelDropdown();
|
||||
// Track which element triggered the dropdown for positioning
|
||||
_profileDropdownTrigger = (e && e.currentTarget) || $('profileChip');
|
||||
api('/api/profiles').then(data => {
|
||||
const openGen = ++_profileDropdownOpenGeneration;
|
||||
const cached = _profileDropdownBestCachedData();
|
||||
|
||||
if(cached && !cached.single_profile_mode){
|
||||
renderProfileDropdown(cached);
|
||||
_openProfileDropdownShell();
|
||||
}else{
|
||||
_renderProfileDropdownLoading();
|
||||
_openProfileDropdownShell();
|
||||
}
|
||||
|
||||
_profileDropdownFetchFresh().then(data => {
|
||||
if(openGen !== _profileDropdownOpenGeneration) return;
|
||||
// In single profile mode, don't show profile dropdown at all
|
||||
if (data.single_profile_mode) {
|
||||
closeProfileDropdown();
|
||||
return;
|
||||
}
|
||||
renderProfileDropdown(data);
|
||||
dd.classList.add('open');
|
||||
_positionProfileDropdown();
|
||||
// Mark the triggering button as active
|
||||
const chip=$('profileChip');
|
||||
if(chip && _profileDropdownTrigger===chip) chip.classList.add('active');
|
||||
const tbtn=$('titlebarProfileBtn');
|
||||
if(tbtn && _profileDropdownTrigger===tbtn) tbtn.classList.add('active');
|
||||
}).catch(e => { showToast(t('profiles_load_failed')); });
|
||||
_openProfileDropdownShell();
|
||||
}).catch(e => {
|
||||
if(openGen !== _profileDropdownOpenGeneration) return;
|
||||
if(cached && !cached.single_profile_mode){
|
||||
// Keep the cached menu open; the next click/background refresh will retry.
|
||||
return;
|
||||
}
|
||||
closeProfileDropdown();
|
||||
showToast(t('profiles_load_failed'));
|
||||
});
|
||||
}
|
||||
|
||||
function closeProfileDropdown() {
|
||||
_profileDropdownOpenGeneration++;
|
||||
const dd = $('profileDropdown');
|
||||
if (dd) dd.classList.remove('open');
|
||||
const chip=$('profileChip');
|
||||
|
||||
@@ -107,7 +107,8 @@ def _function_body(src: str, signature: str) -> str:
|
||||
def test_profile_dropdown_filters_hidden_profiles_but_preserves_active():
|
||||
body = _function_body(_panels_js(), "function renderProfileDropdown(data)")
|
||||
|
||||
assert "const allProfiles = data.profiles || [];" in body
|
||||
assert "data = data || {};" in body
|
||||
assert "const allProfiles = (Array.isArray(data.profiles) ? data.profiles : []).filter(p => p && typeof p.name === 'string');" in body
|
||||
assert "allProfiles.some(p => p.name === S.activeProfile)" in body
|
||||
assert "const profiles = allProfiles.filter(p => p && (p.visible !== false || p.name === active));" in body
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Regression guards for fast profile dropdown opening.
|
||||
|
||||
The user-visible failure was: clicking the composer/titlebar profile chip waited
|
||||
for a cold /api/profiles request before showing the menu. On machines where the
|
||||
profile metadata scan is slow, that made the click feel frozen for seconds.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
||||
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_body(src: str, marker: str, next_marker: str | None = None) -> str:
|
||||
start = src.index(marker)
|
||||
if next_marker is not None:
|
||||
end = src.index(next_marker, start)
|
||||
return src[start:end]
|
||||
depth = 0
|
||||
opened = False
|
||||
for idx, ch in enumerate(src[start:], start):
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
opened = True
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if opened and depth == 0:
|
||||
return src[start : idx + 1]
|
||||
raise AssertionError(f"Could not extract function body for {marker}")
|
||||
|
||||
|
||||
def test_profile_dropdown_opens_shell_before_network_fetch():
|
||||
body = _function_body(PANELS_JS, "function toggleProfileDropdown(e) {")
|
||||
cache_idx = body.index("const cached = _profileDropdownBestCachedData();")
|
||||
open_idx = body.index("_openProfileDropdownShell();")
|
||||
fetch_idx = body.index("_profileDropdownFetchFresh().then")
|
||||
assert cache_idx < open_idx < fetch_idx, (
|
||||
"toggleProfileDropdown must render/open from cache or loading shell before "
|
||||
"awaiting the slow /api/profiles refresh"
|
||||
)
|
||||
|
||||
|
||||
def test_profile_dropdown_uses_shared_fetch_promise_and_validated_local_storage_cache():
|
||||
data_cache_body = _function_body(PANELS_JS, "function _profileDropdownDataCacheUsable(data){")
|
||||
assert "Array.isArray(data.profiles)" in data_cache_body
|
||||
assert "data.profiles.every(" in data_cache_body
|
||||
assert "typeof p.name==='string'" in data_cache_body
|
||||
# #5412 gate: the predicate must also reject rows whose renderer-read `model`
|
||||
# field is a non-string truthy value (poison {name,model:{}} bricked the dropdown).
|
||||
assert "typeof p.model==='string'" in data_cache_body
|
||||
|
||||
cache_body = _function_body(PANELS_JS, "function _profileDropdownCacheUsable(data){")
|
||||
assert "_profileDropdownDataCacheUsable(data)" in cache_body
|
||||
assert "data.single_profile_mode !== true" in cache_body
|
||||
|
||||
fetch_body = _function_body(PANELS_JS, "function _profileDropdownFetchFresh(){")
|
||||
assert "if(_profileDropdownFetchPromise) return _profileDropdownFetchPromise;" in fetch_body
|
||||
assert "api('/api/profiles', {timeoutToast:false})" in fetch_body
|
||||
assert "if(_profileDropdownDataCacheUsable(data)) _profilesCache = data;" in fetch_body
|
||||
assert "_profileDropdownWriteStoredCache(data);" in fetch_body
|
||||
assert "\n _profilesCache = data;\n" not in fetch_body
|
||||
|
||||
best_body = _function_body(PANELS_JS, "function _profileDropdownBestCachedData(){")
|
||||
assert "if(_profileDropdownDataCacheUsable(_profilesCache)) return null;" in best_body
|
||||
assert best_body.index("if(_profileDropdownDataCacheUsable(_profilesCache)) return null;") < best_body.index("_profilesCache = null;")
|
||||
|
||||
read_body = _function_body(PANELS_JS, "function _profileDropdownReadStoredCache(){")
|
||||
assert "localStorage.getItem(PROFILE_DROPDOWN_CACHE_KEY)" in read_body
|
||||
assert "PROFILE_DROPDOWN_CACHE_TTL_MS" in read_body
|
||||
assert "_profileDropdownClearStoredCache();" in read_body
|
||||
|
||||
write_body = _function_body(PANELS_JS, "function _profileDropdownWriteStoredCache(data){")
|
||||
assert "if(!_profileDropdownCacheUsable(data)) { _profileDropdownClearStoredCache(); return; }" in write_body
|
||||
|
||||
|
||||
def test_profile_dropdown_closing_invalidates_inflight_refresh():
|
||||
close_body = _function_body(PANELS_JS, "function closeProfileDropdown() {")
|
||||
assert "_profileDropdownOpenGeneration++;" in close_body
|
||||
|
||||
toggle_body = _function_body(PANELS_JS, "function toggleProfileDropdown(e) {")
|
||||
assert "const openGen = ++_profileDropdownOpenGeneration;" in toggle_body
|
||||
assert "if(openGen !== _profileDropdownOpenGeneration) return;" in toggle_body
|
||||
|
||||
|
||||
def test_profiles_panel_refresh_updates_dropdown_cache():
|
||||
body = _function_body(PANELS_JS, "async function loadProfilesPanel() {")
|
||||
api_idx = body.index("const data = await api('/api/profiles');")
|
||||
cache_idx = body.index("_profileDropdownWriteStoredCache(data);")
|
||||
render_idx = body.index("panel.innerHTML = '';")
|
||||
assert api_idx < cache_idx < render_idx
|
||||
|
||||
|
||||
def test_profile_dropdown_prefetches_after_page_load():
|
||||
assert "function _warmProfileDropdownCache(){" in PANELS_JS
|
||||
assert "window.addEventListener('load'" in PANELS_JS
|
||||
assert "_warmProfileDropdownCache();" in PANELS_JS
|
||||
|
||||
|
||||
def test_poisoned_profile_cache_opens_then_switches_after_fresh_refresh():
|
||||
snippets = [
|
||||
PANELS_JS[
|
||||
PANELS_JS.index("let _profilesCache = null;") : PANELS_JS.index("async function _profileSwitchPanelLoad(){")
|
||||
],
|
||||
_function_body(PANELS_JS, "function renderProfileDropdown(data) {"),
|
||||
_function_body(PANELS_JS, "function toggleProfileDropdown(e) {"),
|
||||
_function_body(PANELS_JS, "function closeProfileDropdown() {"),
|
||||
]
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
const assert = require('assert');
|
||||
const snippets = {json.dumps(snippets)};
|
||||
|
||||
class ClassList {{
|
||||
constructor() {{ this.values = new Set(); }}
|
||||
add(name) {{ this.values.add(name); }}
|
||||
remove(name) {{ this.values.delete(name); }}
|
||||
contains(name) {{ return this.values.has(name); }}
|
||||
}}
|
||||
class Element {{
|
||||
constructor(tag, id) {{
|
||||
this.tagName = tag;
|
||||
this.id = id || '';
|
||||
this.children = [];
|
||||
this.className = '';
|
||||
this.classList = new ClassList();
|
||||
this.dataset = {{}};
|
||||
this.style = {{}};
|
||||
this.onclick = null;
|
||||
this.textContent = '';
|
||||
this._innerHTML = '';
|
||||
}}
|
||||
set innerHTML(value) {{
|
||||
this._innerHTML = String(value || '');
|
||||
if (this._innerHTML === '') this.children = [];
|
||||
}}
|
||||
get innerHTML() {{ return this._innerHTML; }}
|
||||
appendChild(child) {{ this.children.push(child); return child; }}
|
||||
}}
|
||||
const elements = new Map();
|
||||
for (const id of ['profileDropdown', 'profileChip', 'titlebarProfileBtn', 'titlebarProfileLabel']) {{
|
||||
elements.set(id, new Element('div', id));
|
||||
}}
|
||||
globalThis.document = {{
|
||||
createElement: (tag) => new Element(tag),
|
||||
getElementById: (id) => elements.get(id) || null,
|
||||
addEventListener: () => {{}},
|
||||
}};
|
||||
globalThis.window = {{ addEventListener: () => {{}} }};
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {{
|
||||
getItem: (key) => store.has(key) ? store.get(key) : null,
|
||||
setItem: (key, value) => store.set(key, String(value)),
|
||||
removeItem: (key) => store.delete(key),
|
||||
}};
|
||||
globalThis.$ = (id) => elements.get(id) || null;
|
||||
globalThis.S = {{ activeProfile: 'default' }};
|
||||
globalThis.t = (key, n) => key === 'profile_skill_count' ? `${{n}} skills` : key;
|
||||
globalThis.esc = (value) => String(value == null ? '' : value)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
globalThis.li = () => '';
|
||||
globalThis.closeWsDropdown = () => {{}};
|
||||
globalThis.closeModelDropdown = () => {{}};
|
||||
globalThis._positionProfileDropdown = () => {{}};
|
||||
globalThis.showToast = () => {{}};
|
||||
let switchedTo = null;
|
||||
globalThis.switchToProfile = async (name) => {{ switchedTo = name; S.activeProfile = name; }};
|
||||
const multiProfileResponse = {{
|
||||
active: 'default',
|
||||
single_profile_mode: false,
|
||||
profiles: [
|
||||
{{ name: 'default', visible: true, is_default: true }},
|
||||
{{ name: 'other', visible: true }},
|
||||
],
|
||||
}};
|
||||
let apiResponse = multiProfileResponse;
|
||||
globalThis.api = () => Promise.resolve(apiResponse);
|
||||
|
||||
eval(snippets.join(String.fromCharCode(10)) + String.fromCharCode(10) + `;globalThis.__profileTest={{
|
||||
key: PROFILE_DROPDOWN_CACHE_KEY,
|
||||
reset() {{
|
||||
_profilesCache = null;
|
||||
_profileDropdownFetchPromise = null;
|
||||
_profileDropdownCacheLoadedFromStorage = false;
|
||||
_profileDropdownOpenGeneration = 0;
|
||||
switchedTo = null;
|
||||
S.activeProfile = 'default';
|
||||
const dd = document.getElementById('profileDropdown');
|
||||
dd.children = [];
|
||||
dd.innerHTML = '';
|
||||
dd.classList.remove('open');
|
||||
}},
|
||||
usable: _profileDropdownCacheUsable,
|
||||
dataUsable: _profileDropdownDataCacheUsable,
|
||||
setApiResponse(data) {{ apiResponse = data; }},
|
||||
setCache(data) {{ _profilesCache = data; }},
|
||||
cache: () => _profilesCache,
|
||||
profileDetailBacked(name) {{
|
||||
return !!(_profilesCache && _profilesCache.profiles && _profilesCache.profiles.find(x => x.name === name));
|
||||
}},
|
||||
toggle: toggleProfileDropdown,
|
||||
close: closeProfileDropdown,
|
||||
switchedTo: () => switchedTo,
|
||||
}};`);
|
||||
|
||||
async function runPoisonedCase(profiles) {{
|
||||
__profileTest.reset();
|
||||
__profileTest.setApiResponse(multiProfileResponse);
|
||||
localStorage.setItem(__profileTest.key, JSON.stringify({{ ts: Date.now(), data: {{ active: 'default', profiles }} }}));
|
||||
assert.strictEqual(__profileTest.usable({{ active: 'default', profiles }}), false);
|
||||
assert.doesNotThrow(() => __profileTest.toggle({{ currentTarget: document.getElementById('profileChip') }}));
|
||||
const dd = document.getElementById('profileDropdown');
|
||||
assert.strictEqual(dd.classList.contains('open'), true, 'dropdown shell should open from loading state');
|
||||
assert.strictEqual(localStorage.getItem(__profileTest.key), null, 'invalid stored cache should be removed before refresh');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const profileOptions = dd.children.filter((child) => String(child.className).includes('profile-opt'));
|
||||
const other = profileOptions.find((child) => child.innerHTML.includes('other'));
|
||||
assert(other, 'fresh refresh should render the valid profile option');
|
||||
await other.onclick();
|
||||
assert.strictEqual(__profileTest.switchedTo(), 'other');
|
||||
}}
|
||||
|
||||
async function runSingleProfilePreservesSharedCache() {{
|
||||
__profileTest.reset();
|
||||
const singleProfileResponse = {{
|
||||
active: 'default',
|
||||
single_profile_mode: true,
|
||||
profiles: [{{ name: 'default', visible: true, is_default: true }}],
|
||||
}};
|
||||
__profileTest.setApiResponse(singleProfileResponse);
|
||||
__profileTest.setCache(singleProfileResponse);
|
||||
assert.strictEqual(__profileTest.dataUsable(singleProfileResponse), true);
|
||||
assert.strictEqual(__profileTest.usable(singleProfileResponse), false);
|
||||
assert.strictEqual(__profileTest.profileDetailBacked('default'), true, 'single-profile cache should back Profiles panel before click');
|
||||
assert.doesNotThrow(() => __profileTest.toggle({{ currentTarget: document.getElementById('profileChip') }}));
|
||||
assert.strictEqual(__profileTest.profileDetailBacked('default'), true, 'dropdown open must not null the Profiles panel cache synchronously');
|
||||
assert.strictEqual(__profileTest.cache(), singleProfileResponse);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.strictEqual(__profileTest.profileDetailBacked('default'), true, 'fresh single-profile response must keep Profiles panel data');
|
||||
assert.strictEqual(__profileTest.cache().single_profile_mode, true);
|
||||
}}
|
||||
|
||||
async function runFreshSingleProfileReplacesStaleMultiCache() {{
|
||||
__profileTest.reset();
|
||||
const staleMultiProfileResponse = multiProfileResponse;
|
||||
const singleProfileResponse = {{
|
||||
active: 'default',
|
||||
single_profile_mode: true,
|
||||
profiles: [{{ name: 'default', visible: true, is_default: true }}],
|
||||
}};
|
||||
__profileTest.setCache(staleMultiProfileResponse);
|
||||
__profileTest.setApiResponse(singleProfileResponse);
|
||||
assert.strictEqual(__profileTest.usable(staleMultiProfileResponse), true);
|
||||
assert.doesNotThrow(() => __profileTest.toggle({{ currentTarget: document.getElementById('profileChip') }}));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.strictEqual(__profileTest.cache(), singleProfileResponse, 'fresh single-profile payload should replace stale multi-profile memory cache');
|
||||
assert.strictEqual(__profileTest.profileDetailBacked('default'), true);
|
||||
assert.strictEqual(localStorage.getItem(__profileTest.key), null, 'single-profile payload should not be persisted as dropdown cache');
|
||||
}}
|
||||
|
||||
(async () => {{
|
||||
await runPoisonedCase([null]);
|
||||
await runPoisonedCase([{{}}]);
|
||||
// Regression (#5412 gate): a row that PASSES the name check but has a
|
||||
// non-string `model` used to slip the cache predicate and throw
|
||||
// `p.model.split is not a function` synchronously on dropdown open,
|
||||
// bricking profile switching until localStorage was cleared. The
|
||||
// predicate must now reject it (usable===false) AND the renderer must
|
||||
// not throw even if such a row reaches it.
|
||||
await runPoisonedCase([{{ name: 'default', is_default: true, model: {{}} }}, {{ name: 'other', visible: true }}]);
|
||||
await runPoisonedCase([{{ name: 'default', is_default: true, model: 123 }}, {{ name: 'other', visible: true }}]);
|
||||
await runSingleProfilePreservesSharedCache();
|
||||
await runFreshSingleProfileReplacesStaleMultiCache();
|
||||
}})().catch((err) => {{ console.error(err && err.stack || err); process.exit(1); }});
|
||||
"""
|
||||
)
|
||||
subprocess.run(["node", "-e", script], cwd=REPO_ROOT, check=True, text=True, capture_output=True)
|
||||
Reference in new issue
Block a user