Files
hermes-webui/tests/test_api_timeout.py
T
2026-05-20 02:41:00 +08:00

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