Files
odysseus/tests/test_app_config_shared_fetch_js.py
T

376 lines
15 KiB
Python

"""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