"""Pin the shared config cache in static/js/appConfig.js. Background: /api/auth/settings was fetched independently by six modules and /api/tools by three (chatRenderer.js is imported under three different ?v= query strings, so it is three separate module instances) — 4 and 3 requests on one cold load. Beyond the redundant work, each caller could observe a different snapshot of the same object. appConfig.js gives them one promise each. The two properties that matter are opposites, so both are tested here: concurrent and later callers must NOT refetch, and a caller after a write MUST see the new value — which only holds if every writer invalidates. The last test is a source scan that checks exactly that, since a forgotten invalidation serves a stale settings object for the rest of the session, which is worse than the duplicate fetches this replaces. Driven through `node --input-type=module` so the real module runs, same idiom as test_esc_menu_stack_js.py. The module source is inlined rather than imported by path because the repo has no `"type": "module"` in package.json; appConfig.js has no imports of its own, so inlining is exact. `fetch` and `sessionStorage` are stubbed, so nothing here touches the network or depends on timing. """ import json import re import shutil import subprocess from pathlib import Path import pytest _REPO = Path(__file__).resolve().parent.parent _MODULE = _REPO / "static" / "js" / "appConfig.js" _HAS_NODE = shutil.which("node") is not None _SRC = _MODULE.read_text(encoding="utf-8") if _MODULE.exists() else "" # Browser stand-ins. Every fetch is recorded and resolved from a queue the test # controls, so "how many requests went out" is an exact count, not a guess. _STUBS = r""" const calls = []; let responses = []; globalThis.__queue = (fn) => { responses.push(fn); }; globalThis.fetch = (url, opts) => { calls.push([url, opts]); const next = responses.shift(); if (!next) throw new Error('unexpected fetch: ' + url); return next(); }; globalThis.__calls = () => calls; globalThis.__json = (value) => () => Promise.resolve({ json: () => Promise.resolve(value) }); globalThis.__fail = (msg) => () => Promise.reject(new Error(msg)); const store = new Map(); globalThis.sessionStorage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => { store.set(k, String(v)); }, removeItem: (k) => { store.delete(k); }, }; globalThis.__seedPrefetch = (value) => { store.set('ody-prefetch-settings', JSON.stringify(value)); }; globalThis.__prefetchLeft = () => store.has('ody-prefetch-settings'); """ def _run(body: str) -> str: js = _STUBS + "\n" + _SRC + "\n" + body proc = subprocess.run( ["node", "--input-type=module"], input=js, capture_output=True, text=True, encoding="utf-8", cwd=str(_REPO), timeout=30, ) assert proc.returncode == 0, proc.stderr return proc.stdout.strip() @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_concurrent_callers_share_one_request(): # The startup case: several modules ask before the first response lands. body = """ __queue(__json({ tts_enabled: true })); const [a, b, c] = await Promise.all([getSettings(), getSettings(), getSettings()]); console.log(JSON.stringify({ requests: __calls().length, url: __calls()[0][0], credentials: __calls()[0][1].credentials, sameObject: a === b && b === c, value: a.tts_enabled, })); """ assert json.loads(_run(body)) == { "requests": 1, "url": "/api/auth/settings", "credentials": "same-origin", "sameObject": True, "value": True, } @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_later_caller_reuses_the_resolved_snapshot(): # A panel opened long after boot must not re-request. body = """ __queue(__json({ search_provider: 'brave' })); const first = await getSettings(); const second = await getSettings(); console.log(JSON.stringify({ requests: __calls().length, sameObject: first === second })); """ assert json.loads(_run(body)) == {"requests": 1, "sameObject": True} @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_invalidate_forces_the_next_read_to_refetch(): # The write path: save a setting, then read it back and see the new value. body = """ __queue(__json({ tts_enabled: true })); __queue(__json({ tts_enabled: false })); const before = await getSettings(); invalidateSettings(); const after = await getSettings(); console.log(JSON.stringify({ requests: __calls().length, before: before.tts_enabled, after: after.tts_enabled, })); """ assert json.loads(_run(body)) == {"requests": 2, "before": True, "after": False} @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_a_failed_fetch_does_not_poison_the_cache(): # Plain `??=` memoisation would keep the rejected promise, so one blip at # boot would leave keybinds/TTS/search on defaults for the whole session. body = """ __queue(__fail('offline')); __queue(__json({ tts_enabled: true })); let rejected = false; try { await getSettings(); } catch (e) { rejected = e.message === 'offline'; } const retry = await getSettings(); console.log(JSON.stringify({ rejected, requests: __calls().length, recovered: retry.tts_enabled, })); """ assert json.loads(_run(body)) == {"rejected": True, "requests": 2, "recovered": True} @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_settings_and_tools_are_independent_slots(): body = """ __queue(__json({ tts_enabled: true })); __queue(__json({ tools: [{ id: 'web_search' }] })); await getSettings(); const tools = await getTools(); const toolsUrl = __calls()[1][0]; invalidateSettings(); // must not drop the tools snapshot const toolsAgain = await getTools(); console.log(JSON.stringify({ requests: __calls().length, toolsUrl, sameObject: tools === toolsAgain, })); """ assert json.loads(_run(body)) == { "requests": 2, "toolsUrl": "/api/tools", "sameObject": True, } @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_login_prefetch_is_used_once_and_then_consumed(): # login.html stashes a settings snapshot in sessionStorage just before it # redirects, so the first load after a login should issue no request at all. body = """ __seedPrefetch({ tts_enabled: false, from: 'prefetch' }); __queue(__json({ tts_enabled: true, from: 'network' })); const first = await getSettings(); const consumed = !__prefetchLeft(); invalidateSettings(); const second = await getSettings(); console.log(JSON.stringify({ requests: __calls().length, first: first.from, consumed, second: second.from, })); """ assert json.loads(_run(body)) == { "requests": 1, "first": "prefetch", "consumed": True, "second": "network", } # ── Writers must invalidate ───────────────────────────────────────────────── _WRITE_ENDPOINTS = { "/api/auth/settings": "invalidateSettings", "/api/tools": "invalidateTools", } # login.html is the pre-app login page: it has no module graph and its only call # is the prefetch GET, so it is not a writer and cannot import appConfig.js. _SCANNED = [_REPO / "static" / "app.js"] + sorted((_REPO / "static" / "js").rglob("*.js")) def _post_sites(source: str, endpoint: str): """Yield the 1-based line of every fetch() to `endpoint` that is a POST.""" for m in re.finditer(re.escape(f"'{endpoint}'"), source): window = source[m.start():m.start() + 240] if re.search(r"method:\s*'POST'", window): yield source[:m.start()].count("\n") + 1 def test_every_settings_writer_invalidates_the_shared_cache(): """A POST that skips the invalidation serves a stale object for the session. Checked by source scan rather than at runtime: the failure mode is a call site that was never wired up, which no unit test of the cache itself can see. `appConfig.js` itself is skipped — it is the cache, not a writer. """ missing = [] for path in _SCANNED: if path.name == "appConfig.js": continue source = path.read_text(encoding="utf-8") for endpoint, invalidator in _WRITE_ENDPOINTS.items(): for line in _post_sites(source, endpoint): # The invalidation belongs in the same function as the POST; # accept it anywhere in the surrounding 20 lines either way. lines = source.splitlines() near = "\n".join(lines[max(0, line - 20):line + 20]) if invalidator + "(" not in near: missing.append(f"{path.relative_to(_REPO)}:{line} POST {endpoint}") assert not missing, ( "POST sites with no nearby cache invalidation — the UI will serve a " "stale snapshot after these writes:\n " + "\n ".join(missing) ) @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_out_of_band_tool_change_is_not_undone_by_an_unrelated_panel_save(): """Admin > Tools must render authoritative state, not the startup snapshot. The save posts the whole disabled-tool list rebuilt from the checkboxes, so a stale render turns any unrelated toggle into a lost update: a tool disabled out of band (manage_settings, another tab) comes back enabled. refreshAll() calls loadBuiltinTools() on every panel open, which is why the editor drops the shared entry before reading it. """ body = """ // Boot: chatRenderer.js reads the tool list for the exec-fence regex. __queue(__json({ tools: [{ id: 'web_search', enabled: true }, { id: 'shell', enabled: true }] })); const boot = await getTools(); // Out of band, this page hearing nothing about it: web_search is disabled. __queue(__json({ tools: [{ id: 'web_search', enabled: false }, { id: 'shell', enabled: true }] })); // Admin > Tools opens. loadBuiltinTools() invalidates, then reads. invalidateTools(); const panel = await getTools(); // The user toggles one unrelated tool off. The save posts every unchecked // box, so the list is only right if the render was authoritative. const post = (snapshot) => { const boxes = snapshot.tools.map(t => ({ id: t.id, checked: t.enabled })); boxes.find(b => b.id === 'shell').checked = false; return boxes.filter(b => !b.checked).map(b => b.id); }; console.log(JSON.stringify({ requests: __calls().length, posted: post(panel), postedFromStaleSnapshot: post(boot), })); """ assert json.loads(_run(body)) == { "requests": 2, # web_search stays disabled, which is the point. "posted": ["web_search", "shell"], # What the page-lifetime snapshot would have posted: web_search silently # re-enabled by a toggle that had nothing to do with it. "postedFromStaleSnapshot": ["shell"], } @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_out_of_band_tool_change_after_panel_open_is_preserved_on_save(): """Saving must merge the user's edit onto a fresh authoritative snapshot.""" body = """ // Panel opens while both tools are enabled. __queue(__json({ tools: [ { id: 'web_search', enabled: true }, { id: 'shell', enabled: true }, ] })); invalidateTools(); const panel = await getTools(); // Another tab disables web_search after this panel has already rendered. __queue(__json({ tools: [ { id: 'web_search', enabled: false }, { id: 'shell', enabled: true }, ] })); // The user only disables shell. Saving refreshes the authoritative state // first and applies that one intended change on top of it. invalidateTools(); const latest = await getTools(); const state = new Map(latest.tools.map(t => [t.id, !!t.enabled])); state.set('shell', false); const disabled = Array.from(state.entries()) .filter(([, enabled]) => !enabled) .map(([id]) => id); console.log(JSON.stringify({ panelWebSearchEnabled: panel.tools.find(t => t.id === 'web_search').enabled, requests: __calls().length, disabled, })); """ assert json.loads(_run(body)) == { "panelWebSearchEnabled": True, "requests": 2, "disabled": ["web_search", "shell"], } def test_admin_tool_save_refreshes_before_full_state_post(): """Pin the lost-update guard in the Admin Tools full-list writer.""" source = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8") match = re.search( r"async function _saveToolState\(changes\) \{(.*?)\n \}\n" r" function _updateCatCounter", source, re.S, ) assert match, "_saveToolState(changes) not found in static/js/admin.js" body = match.group(1) invalidate = body.find("invalidateTools()") refresh = body.find("getTools()") post = body.find("fetch('/api/tools'") assert -1 not in (invalidate, refresh, post) assert invalidate < refresh < post, ( "Admin Tools must invalidate and refresh authoritative tool state before " "posting the endpoint's full disabled-tools replacement list" ) assert "for (const change of changes)" in body def test_the_admin_tools_editor_does_not_read_a_cached_snapshot(): """Pin the invalidate-before-read in loadBuiltinTools(). A source scan because the failure is an ordering in a call site, not behaviour of the cache: getTools() is doing exactly its job either way. """ source = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8") match = re.search(r"\nasync function loadBuiltinTools\(\) \{\n(.*?)\n\}\n", source, re.S) assert match, "loadBuiltinTools() not found in static/js/admin.js" body = match.group(1) read = body.find("getTools(") assert read != -1, "loadBuiltinTools() no longer reads the shared tool cache" assert "invalidateTools(" in body[:read], ( "loadBuiltinTools() reads the shared /api/tools snapshot without dropping " "it first, so a reopened panel can render tool state that changed out of " "band and re-post it on the next unrelated toggle" ) def test_appconfig_is_precached_by_the_service_worker(): """PRECACHE is hand-maintained; a module missing from it breaks offline.""" sw = (_REPO / "static" / "sw.js").read_text(encoding="utf-8") assert "'/static/js/appConfig.js'" in sw