A poisoned cache row {name:'x', model:{}} passed the name-only cache predicate
but threw 'p.model.split is not a function' synchronously on profile-chip click,
bricking profile switching until localStorage was cleared. Guard both render
sites (typeof p.model==='string') and reject unsafe model types in
_profileDropdownDataCacheUsable; regression covers {model:{}} and {model:123}.
280 lines
14 KiB
Python
280 lines
14 KiB
Python
"""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)
|