mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
195 lines
8.0 KiB
Python
195 lines
8.0 KiB
Python
"""Regression coverage for #2539 client-side api() timeout handling."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
WORKSPACE_JS = ROOT / "static" / "workspace.js"
|
|
SESSIONS_JS = ROOT / "static" / "sessions.js"
|
|
UI_JS = ROOT / "static" / "ui.js"
|
|
PANELS_JS = ROOT / "static" / "panels.js"
|
|
|
|
|
|
def _source(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def _extract_js_function(src: str, name: str) -> str:
|
|
marker = f"async function {name}("
|
|
start = src.find(marker)
|
|
assert start >= 0, f"{name}() function must exist"
|
|
# The api() signature contains a default object literal (`opts={}`), so the
|
|
# function-body brace is the first `{` after the balanced parameter list.
|
|
paren_depth = 0
|
|
close_paren = -1
|
|
for idx in range(start + len(f"async function {name}"), len(src)):
|
|
ch = src[idx]
|
|
if ch == "(":
|
|
paren_depth += 1
|
|
elif ch == ")":
|
|
paren_depth -= 1
|
|
if paren_depth == 0:
|
|
close_paren = idx
|
|
break
|
|
assert close_paren > start, f"{name}() parameter list must close"
|
|
brace = src.find("{", close_paren)
|
|
assert brace > close_paren, f"{name}() function body must start with {{"
|
|
depth = 0
|
|
in_string: str | None = None
|
|
escaped = False
|
|
in_line_comment = False
|
|
in_block_comment = False
|
|
for idx in range(brace, len(src)):
|
|
ch = src[idx]
|
|
nxt = src[idx + 1] if idx + 1 < len(src) else ""
|
|
if in_line_comment:
|
|
if ch == "\n":
|
|
in_line_comment = False
|
|
continue
|
|
if in_block_comment:
|
|
if ch == "*" and nxt == "/":
|
|
in_block_comment = False
|
|
continue
|
|
if in_string:
|
|
if escaped:
|
|
escaped = False
|
|
elif ch == "\\":
|
|
escaped = True
|
|
elif ch == in_string:
|
|
in_string = None
|
|
continue
|
|
if ch == "/" and nxt == "/":
|
|
in_line_comment = True
|
|
continue
|
|
if ch == "/" and nxt == "*":
|
|
in_block_comment = True
|
|
continue
|
|
if ch in ("'", '"', "`"):
|
|
in_string = ch
|
|
continue
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return src[start : idx + 1]
|
|
raise AssertionError(f"could not extract {name}() body")
|
|
|
|
|
|
def _node_eval(script: str, timeout: float = 2.0) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=ROOT,
|
|
text=True,
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def test_api_rejects_hung_fetch_with_timeout_and_toast():
|
|
"""A hung fetch must reject quickly and surface a recognizable timeout toast."""
|
|
api_fn = _extract_js_function(_source(WORKSPACE_JS), "api")
|
|
script = textwrap.dedent(
|
|
f"""
|
|
const events=[];
|
|
global.document={{baseURI:'http://example.test/hermes/'}};
|
|
global.location={{href:'http://example.test/hermes/',pathname:'/hermes/',search:''}};
|
|
global.window={{location:global.location}};
|
|
global.showToast=(msg,ms,type)=>events.push({{msg:String(msg),ms,type}});
|
|
global.fetch=(url,opts)=>new Promise(()=>{{
|
|
if(opts&&opts.signal)opts.signal.addEventListener('abort',()=>events.push({{aborted:true}}));
|
|
}});
|
|
{api_fn}
|
|
api('/api/sessions',{{timeoutMs:20}})
|
|
.then(()=>{{console.error('resolved unexpectedly');process.exit(2);}})
|
|
.catch(err=>{{
|
|
console.log(JSON.stringify({{message:String(err&&err.message||err),events}}));
|
|
process.exit(0);
|
|
}});
|
|
setTimeout(()=>{{console.error('api did not reject after timeoutMs');process.exit(3);}},250);
|
|
"""
|
|
)
|
|
result = _node_eval(script, timeout=1.0)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip())
|
|
assert "timed out" in payload["message"].lower()
|
|
assert any(event.get("aborted") for event in payload["events"]), payload
|
|
assert any("request timed out" in event.get("msg", "").lower() for event in payload["events"]), payload
|
|
assert any(event.get("type") == "error" for event in payload["events"]), payload
|
|
|
|
|
|
def test_api_rejects_stalled_response_body_with_timeout():
|
|
"""The timeout must stay active through JSON/text body consumption, not only headers."""
|
|
api_fn = _extract_js_function(_source(WORKSPACE_JS), "api")
|
|
script = textwrap.dedent(
|
|
f"""
|
|
const events=[];
|
|
global.document={{baseURI:'http://example.test/hermes/'}};
|
|
global.location={{href:'http://example.test/hermes/',pathname:'/hermes/',search:''}};
|
|
global.window={{location:global.location}};
|
|
global.showToast=(msg,ms,type)=>events.push({{msg:String(msg),ms,type}});
|
|
global.fetch=(url,opts)=>Promise.resolve({{
|
|
ok:true,
|
|
headers:{{get:()=> 'application/json'}},
|
|
json:()=>new Promise(()=>{{
|
|
if(opts&&opts.signal)opts.signal.addEventListener('abort',()=>events.push({{aborted:true}}));
|
|
}}),
|
|
text:()=>Promise.resolve('')
|
|
}});
|
|
{api_fn}
|
|
api('/api/sessions',{{timeoutMs:20}})
|
|
.then(()=>{{console.error('resolved unexpectedly');process.exit(2);}})
|
|
.catch(err=>{{
|
|
console.log(JSON.stringify({{message:String(err&&err.message||err),events}}));
|
|
process.exit(0);
|
|
}});
|
|
setTimeout(()=>{{console.error('api body read did not reject after timeoutMs');process.exit(3);}},250);
|
|
"""
|
|
)
|
|
result = _node_eval(script, timeout=1.0)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
payload = json.loads(result.stdout.strip())
|
|
assert "timed out" in payload["message"].lower()
|
|
assert any(event.get("aborted") for event in payload["events"]), payload
|
|
|
|
|
|
def test_api_has_default_timeout_and_per_call_override_contract():
|
|
src = _source(WORKSPACE_JS)
|
|
body = _extract_js_function(src, "api")
|
|
assert "timeoutMs" in body, "api() must accept opts.timeoutMs as a per-call override"
|
|
assert "30000" in body, "api() must default browser API calls to a 30s timeout"
|
|
assert "AbortController" in body, "api() must abort hung fetches with AbortController"
|
|
assert "delete fetchOpts.timeoutMs" in body, "api() must strip timeoutMs before calling fetch()"
|
|
fetch_call = re.search(r"fetch\(url\.href,\{.*?\.\.\.fetchOpts.*?\}\)", body, re.DOTALL)
|
|
assert fetch_call, "api() must call fetch() with sanitized fetchOpts"
|
|
assert "...opts" not in fetch_call.group(0), "api() must not spread raw opts into fetch()"
|
|
assert "timeoutMs" not in fetch_call.group(0), "api() must not forward timeoutMs to fetch()"
|
|
|
|
|
|
def test_update_flows_keep_explicit_longer_timeouts():
|
|
"""Legitimately long update flows should not inherit the generic 30s guard."""
|
|
src = _source(UI_JS)
|
|
panels = _source(PANELS_JS)
|
|
assert "api('/api/updates/check?force=1',{timeoutMs:60000})" in panels
|
|
assert "api('/api/updates/summary',{method:'POST',body:JSON.stringify({updates:scopedUpdates,target:target||null}),timeoutMs:60000})" in src
|
|
assert "api('/api/updates/apply',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000})" in src
|
|
assert "api('/api/updates/force',{method:'POST',body:JSON.stringify({target}),timeoutMs:120000})" in src
|
|
|
|
|
|
def test_new_session_inflight_cleanup_still_runs_after_api_rejects():
|
|
"""newSession() must keep its finally cleanup path so timeout rejections unpin the UI."""
|
|
src = _source(SESSIONS_JS)
|
|
start = src.find("async function newSession")
|
|
assert start >= 0, "newSession() must exist"
|
|
finally_idx = src.find("}finally{", start)
|
|
assert finally_idx > start, "newSession() must keep a finally cleanup block"
|
|
block = src[finally_idx : src.find("\n}", finally_idx) + 2]
|
|
assert "_newSessionInFlight=null" in block
|
|
assert "_setNewSessionPending(false)" in block
|