fix(ui): parse running/enabled into status; expand e2e to 13 tests
Backend /agents response shape: {id, name, version, desc, enabled: bool,
running: bool, pid, instances, config_path}. parse_agents() was reading
nonexistent fields "status", "uptime_seconds", "messages_24h" so the
table showed every agent as "unknown".
Derive status from running + enabled:
running=true → "running"
running=false, enabled=false → "disabled"
running=false, enabled=true → "stopped"
E2E suite now covers (13 tests, all passing in ~9s):
- connect happy + bad-host + missing-apikey + URL-trim
- shape contract (id/name/running/enabled present + bool)
- SSE smoke: /sse/status and /sse/agents/{id}/logs must NOT return
"streaming unsupported" (depends on backend statusWriter.Flush fix
shipped in agents_and_robots master 4822208)
- auth boundary: /health no-auth, /agents 401 sin Bearer + 401 con key falsa
- control: POST /agents/test-bot/start returns valid JSON
- detail: GET /agents/{id} returns id + logs[]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -242,7 +242,11 @@ static std::string make_url(const AppState& s, const std::string& path) {
|
||||
return base + path;
|
||||
}
|
||||
|
||||
// Parse agents JSON array from /agents endpoint
|
||||
// Parse agents JSON array from /agents endpoint.
|
||||
// Backend shape (agents_and_robots/internal/api/handlers.go):
|
||||
// { id, name, version, desc, enabled: bool, running: bool, pid: int,
|
||||
// instances: int, config_path }
|
||||
// status is derived; uptime/msg_24h not provided yet (v0.2 backend work).
|
||||
static std::vector<AgentRow> parse_agents(const std::string& body) {
|
||||
std::vector<AgentRow> rows;
|
||||
auto j = json::parse(body, nullptr, false);
|
||||
@@ -251,9 +255,13 @@ static std::vector<AgentRow> parse_agents(const std::string& body) {
|
||||
AgentRow r;
|
||||
r.id = a.value("id", "");
|
||||
r.display_name = a.value("name", r.id);
|
||||
r.status = a.value("status", "unknown");
|
||||
r.uptime_s = a.value("uptime_seconds", (long long)0);
|
||||
r.msg_24h = a.value("messages_24h", 0);
|
||||
bool enabled = a.value("enabled", false);
|
||||
bool running = a.value("running", false);
|
||||
if (running) r.status = "running";
|
||||
else if (!enabled) r.status = "disabled";
|
||||
else r.status = "stopped";
|
||||
r.uptime_s = 0;
|
||||
r.msg_24h = a.value("instances", 0); // hack: show instances until backend exposes msg_24h
|
||||
rows.push_back(std::move(r));
|
||||
}
|
||||
return rows;
|
||||
|
||||
@@ -134,3 +134,125 @@ def test_url_trim_robust_to_whitespace():
|
||||
r = _run_connect_test(url)
|
||||
assert r.returncode == 0, f"trim failed: stderr={r.stderr!r}"
|
||||
assert r.stdout.startswith("OK ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend direct: shape contract + SSE + control endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _curl(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess:
|
||||
curl_bin = shutil.which("curl.exe") or "/mnt/c/Windows/System32/curl.exe"
|
||||
return subprocess.run([curl_bin, *args], capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def test_agents_shape_has_fields_used_by_ui():
|
||||
"""Backend /agents response must carry the fields parse_agents() reads."""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-fsS",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/agents",
|
||||
])
|
||||
assert r.returncode == 0, r.stderr
|
||||
agents = json.loads(r.stdout)
|
||||
assert isinstance(agents, list) and len(agents) > 0
|
||||
required = {"id", "name", "enabled", "running"}
|
||||
for a in agents:
|
||||
missing = required - set(a.keys())
|
||||
assert not missing, f"agent {a.get('id')} missing fields {missing}: {a}"
|
||||
assert isinstance(a["enabled"], bool)
|
||||
assert isinstance(a["running"], bool)
|
||||
|
||||
|
||||
def test_sse_status_streams_not_unsupported():
|
||||
"""/sse/status must return text/event-stream, NOT plain 'streaming unsupported'.
|
||||
|
||||
Regression: statusWriter wrapper used to hide http.Flusher → SSE handlers
|
||||
aborted with 500 'streaming unsupported'. Fixed in agents_and_robots
|
||||
commit 4822208 by adding statusWriter.Flush().
|
||||
"""
|
||||
apikey = _apikey()
|
||||
# -m 3 = max 3s; SSE keeps the connection open, so we capture initial bytes
|
||||
# and stop. -i = include response headers.
|
||||
r = _curl([
|
||||
"-sN", "-i", "-m", "3",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/sse/status",
|
||||
], timeout=8)
|
||||
body = r.stdout
|
||||
assert "streaming unsupported" not in body, f"SSE not flusher-aware: {body[:300]!r}"
|
||||
# Should see SSE content-type header
|
||||
assert "text/event-stream" in body.lower(), f"no SSE content-type: {body[:300]!r}"
|
||||
|
||||
|
||||
def test_sse_logs_streams_not_unsupported():
|
||||
"""/sse/agents/{id}/logs idem."""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-sN", "-i", "-m", "3",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/sse/agents/assistant-bot/logs",
|
||||
], timeout=8)
|
||||
body = r.stdout
|
||||
assert "streaming unsupported" not in body, f"SSE not flusher-aware: {body[:300]!r}"
|
||||
assert "text/event-stream" in body.lower(), f"no SSE content-type: {body[:300]!r}"
|
||||
|
||||
|
||||
def test_health_no_auth_required():
|
||||
"""/health must respond 200 without any Authorization header."""
|
||||
r = _curl(["-fsS", f"{_url()}/health"])
|
||||
assert r.returncode == 0, r.stderr
|
||||
body = json.loads(r.stdout)
|
||||
assert body.get("status") == "ok"
|
||||
|
||||
|
||||
def test_agents_unauthorized_without_bearer():
|
||||
"""/agents must return 401 without auth."""
|
||||
r = _curl(["-s", "-o", "/dev/null", "-w", "%{http_code}", f"{_url()}/agents"])
|
||||
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
|
||||
|
||||
|
||||
def test_agents_unauthorized_with_bad_bearer():
|
||||
"""/agents must return 401 with wrong apikey."""
|
||||
r = _curl([
|
||||
"-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"-H", "Authorization: Bearer wrong-key-xxxxx",
|
||||
f"{_url()}/agents",
|
||||
])
|
||||
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
|
||||
|
||||
|
||||
def test_control_start_endpoint_responds():
|
||||
"""POST /agents/{id}/start returns JSON (idempotent in unified mode).
|
||||
|
||||
Note: in unified mode (current backend), individual start/stop is a partial
|
||||
no-op — Manager.Start returns success since the goroutine is already running.
|
||||
Real per-agent control is a v0.2 backend feature (see issue 0131 if filed).
|
||||
Here we only assert the endpoint is reachable + returns valid JSON.
|
||||
"""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-sS", "-X", "POST",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/agents/test-bot/start",
|
||||
])
|
||||
assert r.returncode == 0, r.stderr
|
||||
body = json.loads(r.stdout)
|
||||
# accept either {status: started, ...} or {error: ...} — only assert it's JSON
|
||||
assert isinstance(body, dict)
|
||||
|
||||
|
||||
def test_get_single_agent_returns_logs():
|
||||
"""GET /agents/{id} must include 'logs' array (might be empty)."""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-fsS",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/agents/assistant-bot",
|
||||
])
|
||||
assert r.returncode == 0, r.stderr
|
||||
body = json.loads(r.stdout)
|
||||
assert body.get("id") == "assistant-bot"
|
||||
assert "logs" in body
|
||||
assert isinstance(body["logs"], list)
|
||||
|
||||
Reference in New Issue
Block a user