feat(web-console): cherry-pick React web console GUI from gary-the-ai fork
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 59s
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 59s
Cherry-pick the Hermes Web Console from gary-the-ai/hermes-web-console-gui. React + TypeScript frontend with Vite, Python aiohttp backend API. Components: - web_console/ — React frontend (chat, sessions, memory, settings, skills, gateway config, cron, workspace, tools, browser, insights pages) - gateway/web_console/ — Python backend API (23 endpoints, SSE event bus, 11 service modules) - gateway/platforms/api_server_ui.py — embedded browser UI for API server - gateway/platforms/api_server.py — route registration refactored into _register_routes(), web console mounted via maybe_register_web_console() - run-gui.sh / setup-gui.sh — one-command launch and setup scripts - tests/gateway/test_api_server_gui_mount.py — 4 integration tests (passing) - tests/web_console/ — 13 backend test files (51 passing) - docs/plans/ — implementation plan, API schema, frontend architecture Fix: added missing ModelContextError class and CRON_MIN_CONTEXT_TOKENS to cron/scheduler.py (pre-existing import bug). Closes #325
This commit is contained in:
224
tests/web_console/test_gateway_admin_api.py
Normal file
224
tests/web_console/test_gateway_admin_api.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Tests for gateway admin web-console API routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from gateway.web_console.api.gateway_admin import GATEWAY_SERVICE_APP_KEY
|
||||
from gateway.web_console.routes import register_web_console_routes
|
||||
|
||||
|
||||
class FakeGatewayService:
|
||||
def __init__(self) -> None:
|
||||
self.approve_calls: list[tuple[str, str]] = []
|
||||
self.revoke_calls: list[tuple[str, str]] = []
|
||||
|
||||
def get_overview(self) -> dict:
|
||||
return {
|
||||
"gateway": {"running": True, "pid": 4242, "state": "running", "exit_reason": None, "updated_at": "2026-03-30T21:48:00Z"},
|
||||
"summary": {
|
||||
"platform_count": 2,
|
||||
"enabled_platforms": 1,
|
||||
"configured_platforms": 1,
|
||||
"connected_platforms": 1,
|
||||
"pending_pairings": 1,
|
||||
"approved_pairings": 2,
|
||||
},
|
||||
}
|
||||
|
||||
def get_platforms(self) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"key": "discord",
|
||||
"label": "Discord",
|
||||
"enabled": True,
|
||||
"configured": True,
|
||||
"runtime_state": "connected",
|
||||
"error_code": None,
|
||||
"error_message": None,
|
||||
"updated_at": "2026-03-30T21:49:00Z",
|
||||
"home_channel": {"platform": "discord", "chat_id": "chan-1", "name": "Home"},
|
||||
"auth": {"mode": "pairing", "allow_all": False, "allowlist_count": 0, "pairing_behavior": "pair"},
|
||||
"pairing": {"pending_count": 1, "approved_count": 2},
|
||||
"extra": {},
|
||||
}
|
||||
]
|
||||
|
||||
def get_pairing_state(self) -> dict:
|
||||
return {
|
||||
"pending": [
|
||||
{"platform": "discord", "code": "ABC12345", "user_id": "u-1", "user_name": "Ada", "age_minutes": 3}
|
||||
],
|
||||
"approved": [
|
||||
{"platform": "discord", "user_id": "u-2", "user_name": "Grace", "approved_at": 123.0}
|
||||
],
|
||||
"summary": {
|
||||
"pending_count": 1,
|
||||
"approved_count": 1,
|
||||
"platforms_with_pending": ["discord"],
|
||||
"platforms_with_approved": ["discord"],
|
||||
},
|
||||
}
|
||||
|
||||
def approve_pairing(self, *, platform: str, code: str) -> dict | None:
|
||||
self.approve_calls.append((platform, code))
|
||||
if platform == "discord" and code == "abc12345":
|
||||
return {
|
||||
"platform": "discord",
|
||||
"code": "ABC12345",
|
||||
"user": {"user_id": "u-1", "user_name": "Ada"},
|
||||
}
|
||||
return None
|
||||
|
||||
def revoke_pairing(self, *, platform: str, user_id: str) -> bool:
|
||||
self.revoke_calls.append((platform, user_id))
|
||||
return platform == "discord" and user_id == "u-2"
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self) -> None:
|
||||
self.restart_calls: list[tuple[bool, bool]] = []
|
||||
|
||||
def request_restart(self, *, detached: bool = False, via_service: bool = False) -> bool:
|
||||
self.restart_calls.append((detached, via_service))
|
||||
return True
|
||||
|
||||
|
||||
class RestartCapableAdapter:
|
||||
class _Handler:
|
||||
def __init__(self, runner: FakeRunner) -> None:
|
||||
self.__self__ = runner
|
||||
|
||||
def __init__(self, runner: FakeRunner) -> None:
|
||||
self._message_handler = self._Handler(runner)
|
||||
|
||||
|
||||
class TestGatewayAdminApiRoutes:
|
||||
@staticmethod
|
||||
async def _make_client(service: FakeGatewayService) -> TestClient:
|
||||
app = web.Application()
|
||||
app[GATEWAY_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overview_platforms_and_pairing_routes(self):
|
||||
service = FakeGatewayService()
|
||||
client = await self._make_client(service)
|
||||
try:
|
||||
overview_resp = await client.get("/api/gui/gateway/overview")
|
||||
assert overview_resp.status == 200
|
||||
overview_payload = await overview_resp.json()
|
||||
assert overview_payload["ok"] is True
|
||||
assert overview_payload["overview"]["gateway"]["pid"] == 4242
|
||||
assert overview_payload["overview"]["summary"]["pending_pairings"] == 1
|
||||
|
||||
platforms_resp = await client.get("/api/gui/gateway/platforms")
|
||||
assert platforms_resp.status == 200
|
||||
platforms_payload = await platforms_resp.json()
|
||||
assert platforms_payload["ok"] is True
|
||||
assert platforms_payload["platforms"][0]["key"] == "discord"
|
||||
assert platforms_payload["platforms"][0]["auth"]["mode"] == "pairing"
|
||||
|
||||
pairing_resp = await client.get("/api/gui/gateway/pairing")
|
||||
assert pairing_resp.status == 200
|
||||
pairing_payload = await pairing_resp.json()
|
||||
assert pairing_payload["ok"] is True
|
||||
assert pairing_payload["pairing"]["pending"][0]["code"] == "ABC12345"
|
||||
assert pairing_payload["pairing"]["summary"]["approved_count"] == 1
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pairing_approve_and_revoke_routes(self):
|
||||
service = FakeGatewayService()
|
||||
client = await self._make_client(service)
|
||||
try:
|
||||
approve_resp = await client.post(
|
||||
"/api/gui/gateway/pairing/approve",
|
||||
json={"platform": "discord", "code": "abc12345"},
|
||||
)
|
||||
assert approve_resp.status == 200
|
||||
approve_payload = await approve_resp.json()
|
||||
assert approve_payload["ok"] is True
|
||||
assert approve_payload["pairing"]["code"] == "ABC12345"
|
||||
assert approve_payload["pairing"]["user"]["user_id"] == "u-1"
|
||||
assert service.approve_calls == [("discord", "abc12345")]
|
||||
|
||||
revoke_resp = await client.post(
|
||||
"/api/gui/gateway/pairing/revoke",
|
||||
json={"platform": "discord", "user_id": "u-2"},
|
||||
)
|
||||
assert revoke_resp.status == 200
|
||||
revoke_payload = await revoke_resp.json()
|
||||
assert revoke_payload["ok"] is True
|
||||
assert revoke_payload["pairing"]["revoked"] is True
|
||||
assert revoke_payload["pairing"]["user_id"] == "u-2"
|
||||
assert service.revoke_calls == [("discord", "u-2")]
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pairing_routes_return_structured_validation_and_not_found_errors(self):
|
||||
service = FakeGatewayService()
|
||||
client = await self._make_client(service)
|
||||
try:
|
||||
invalid_json_resp = await client.post(
|
||||
"/api/gui/gateway/pairing/approve",
|
||||
data="not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert invalid_json_resp.status == 400
|
||||
invalid_json_payload = await invalid_json_resp.json()
|
||||
assert invalid_json_payload["ok"] is False
|
||||
assert invalid_json_payload["error"]["code"] == "invalid_json"
|
||||
|
||||
invalid_platform_resp = await client.post(
|
||||
"/api/gui/gateway/pairing/approve",
|
||||
json={"platform": "", "code": "abc12345"},
|
||||
)
|
||||
assert invalid_platform_resp.status == 400
|
||||
invalid_platform_payload = await invalid_platform_resp.json()
|
||||
assert invalid_platform_payload["error"]["code"] == "invalid_platform"
|
||||
|
||||
approve_missing_resp = await client.post(
|
||||
"/api/gui/gateway/pairing/approve",
|
||||
json={"platform": "discord", "code": "missing"},
|
||||
)
|
||||
assert approve_missing_resp.status == 404
|
||||
approve_missing_payload = await approve_missing_resp.json()
|
||||
assert approve_missing_payload["error"]["code"] == "pairing_not_found"
|
||||
assert approve_missing_payload["error"]["code"] != ""
|
||||
|
||||
revoke_missing_resp = await client.post(
|
||||
"/api/gui/gateway/pairing/revoke",
|
||||
json={"platform": "discord", "user_id": "missing"},
|
||||
)
|
||||
assert revoke_missing_resp.status == 404
|
||||
revoke_missing_payload = await revoke_missing_resp.json()
|
||||
assert revoke_missing_payload["error"]["code"] == "paired_user_not_found"
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_route_requests_gateway_restart_when_runner_is_available(self):
|
||||
service = FakeGatewayService()
|
||||
runner = FakeRunner()
|
||||
app = web.Application()
|
||||
app[GATEWAY_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app, adapter=RestartCapableAdapter(runner))
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
try:
|
||||
resp = await client.post("/api/gui/gateway/restart", json={})
|
||||
assert resp.status == 200
|
||||
payload = await resp.json()
|
||||
assert payload["ok"] is True
|
||||
assert payload["accepted"] is True
|
||||
assert runner.restart_calls == [(True, False)]
|
||||
finally:
|
||||
await client.close()
|
||||
Reference in New Issue
Block a user