From b61626b759252ca32369a31a25f244171d9485bc Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 22 May 2026 22:34:57 +0200 Subject: [PATCH] fix(ui): parse running/enabled into status; expand e2e to 13 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- main.cpp | 16 +++-- tests/test_connect_e2e.py | 122 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/main.cpp b/main.cpp index f615ebe..a7caaca 100644 --- a/main.cpp +++ b/main.cpp @@ -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 parse_agents(const std::string& body) { std::vector rows; auto j = json::parse(body, nullptr, false); @@ -251,9 +255,13 @@ static std::vector 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; diff --git a/tests/test_connect_e2e.py b/tests/test_connect_e2e.py index 9d4bf06..15dbaaf 100644 --- a/tests/test_connect_e2e.py +++ b/tests/test_connect_e2e.py @@ -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)