diff --git a/agents/pytest.ini b/agents/pytest.ini new file mode 100644 index 0000000..df9d25e --- /dev/null +++ b/agents/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +asyncio_mode = auto \ No newline at end of file diff --git a/agents/tests/__init__.py b/agents/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/tests/test_hermes_agent.py b/agents/tests/test_hermes_agent.py new file mode 100644 index 0000000..9c01bc2 --- /dev/null +++ b/agents/tests/test_hermes_agent.py @@ -0,0 +1,370 @@ +""" +Tests for agents/hermes_agent.py + +Run: + pip install pytest pytest-asyncio requests + cd agents + pytest tests/test_hermes_agent.py -v +""" + +import asyncio +import json +import os +import sys +import time +import types +import unittest +from unittest.mock import AsyncMock, MagicMock, patch, call + +import pytest + +# --------------------------------------------------------------------------- +# Helpers to import the module with controlled env vars +# --------------------------------------------------------------------------- + +def _import_hermes(env_overrides=None): + """Import (or re-import) hermes_agent with the given env overrides.""" + import importlib + overrides = env_overrides or {} + env_patch = { + "GATEWAY_URL": overrides.get("GATEWAY_URL", "ws://localhost:8765"), + "GATEWAY_TOKEN": overrides.get("GATEWAY_TOKEN", ""), + "OLLAMA_URL": overrides.get("OLLAMA_URL", "http://localhost:11434"), + "DEFAULT_MODEL": overrides.get("DEFAULT_MODEL", "llama3"), + } + # Strip empty strings so unset vars don't clobber defaults via os.environ.get fallbacks + env_patch_clean = {k: v for k, v in env_patch.items() if v != ""} + # Remove keys not in overrides so module picks up its own defaults + if "GATEWAY_TOKEN" not in overrides: + env_patch_clean.pop("GATEWAY_TOKEN", None) + + with patch.dict(os.environ, env_patch_clean, clear=False): + if "hermes_agent" in sys.modules: + del sys.modules["hermes_agent"] + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + import hermes_agent + return hermes_agent + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _ensure_sys_path(): + agents_dir = os.path.join(os.path.dirname(__file__), "..") + if agents_dir not in sys.path: + sys.path.insert(0, agents_dir) + yield + # Cleanup imported module so each test gets a fresh import + sys.modules.pop("hermes_agent", None) + + +# --------------------------------------------------------------------------- +# ollama_available() +# --------------------------------------------------------------------------- + +class TestOllamaAvailable: + + def test_returns_true_when_status_200(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.status_code = 200 + with patch("hermes_agent.requests.get", return_value=mock_resp) as mock_get: + result = hermes_agent.ollama_available() + assert result is True + mock_get.assert_called_once() + + def test_returns_false_when_status_non_200(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.status_code = 503 + with patch("hermes_agent.requests.get", return_value=mock_resp): + result = hermes_agent.ollama_available() + assert result is False + + def test_returns_false_on_connection_error(self): + import hermes_agent + with patch("hermes_agent.requests.get", side_effect=ConnectionError("refused")): + result = hermes_agent.ollama_available() + assert result is False + + def test_returns_false_on_timeout(self): + import hermes_agent + import requests as req_lib + with patch("hermes_agent.requests.get", side_effect=req_lib.exceptions.Timeout()): + result = hermes_agent.ollama_available() + assert result is False + + def test_uses_correct_endpoint(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.status_code = 200 + with patch("hermes_agent.requests.get", return_value=mock_resp) as mock_get: + hermes_agent.ollama_available() + called_url = mock_get.call_args[0][0] + assert called_url.endswith("/api/tags") + + def test_uses_timeout_3(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.status_code = 200 + with patch("hermes_agent.requests.get", return_value=mock_resp) as mock_get: + hermes_agent.ollama_available() + assert mock_get.call_args[1].get("timeout") == 3 + + +# --------------------------------------------------------------------------- +# ollama_models() +# --------------------------------------------------------------------------- + +class TestOllamaModels: + + def test_returns_list_of_model_names(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.json.return_value = { + "models": [{"name": "llama3"}, {"name": "mistral"}, {"name": "crabdeck"}] + } + with patch("hermes_agent.requests.get", return_value=mock_resp): + result = hermes_agent.ollama_models() + assert result == ["llama3", "mistral", "crabdeck"] + + def test_returns_empty_list_when_no_models_key(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.json.return_value = {} + with patch("hermes_agent.requests.get", return_value=mock_resp): + result = hermes_agent.ollama_models() + assert result == [] + + def test_returns_empty_list_on_network_error(self): + import hermes_agent + with patch("hermes_agent.requests.get", side_effect=ConnectionError("refused")): + result = hermes_agent.ollama_models() + assert result == [] + + def test_returns_empty_list_on_json_error(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.json.side_effect = ValueError("bad json") + with patch("hermes_agent.requests.get", return_value=mock_resp): + result = hermes_agent.ollama_models() + assert result == [] + + def test_returns_empty_list_when_models_is_empty(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.json.return_value = {"models": []} + with patch("hermes_agent.requests.get", return_value=mock_resp): + result = hermes_agent.ollama_models() + assert result == [] + + def test_single_model(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.json.return_value = {"models": [{"name": "phi3"}]} + with patch("hermes_agent.requests.get", return_value=mock_resp): + result = hermes_agent.ollama_models() + assert result == ["phi3"] + + +# --------------------------------------------------------------------------- +# ollama_generate() +# --------------------------------------------------------------------------- + +class TestOllamaGenerate: + + def test_returns_response_text_on_success(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.json.return_value = {"response": "Hello from Ollama!"} + mock_resp.raise_for_status = MagicMock() + with patch("hermes_agent.requests.post", return_value=mock_resp): + result = hermes_agent.ollama_generate("Say hello") + assert result == "Hello from Ollama!" + + def test_returns_fallback_when_no_response_key(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + with patch("hermes_agent.requests.post", return_value=mock_resp): + result = hermes_agent.ollama_generate("ping") + assert result == "(no response from Ollama)" + + def test_returns_error_string_on_http_error(self): + import hermes_agent + import requests as req_lib + mock_resp = MagicMock() + mock_resp.raise_for_status.side_effect = req_lib.exceptions.HTTPError("500") + with patch("hermes_agent.requests.post", return_value=mock_resp): + result = hermes_agent.ollama_generate("prompt") + assert result.startswith("[Hermes error]") + + def test_returns_error_string_on_connection_error(self): + import hermes_agent + with patch("hermes_agent.requests.post", side_effect=ConnectionError("refused")): + result = hermes_agent.ollama_generate("prompt") + assert result.startswith("[Hermes error]") + + def test_posts_correct_payload_structure(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.json.return_value = {"response": "ok"} + mock_resp.raise_for_status = MagicMock() + with patch("hermes_agent.requests.post", return_value=mock_resp) as mock_post: + hermes_agent.ollama_generate("my prompt", "mistral") + _, kwargs = mock_post.call_args + body = kwargs["json"] + assert body["model"] == "mistral" + assert body["prompt"] == "my prompt" + assert body["stream"] is False + + def test_uses_default_model_when_none_specified(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.json.return_value = {"response": "ok"} + mock_resp.raise_for_status = MagicMock() + with patch("hermes_agent.requests.post", return_value=mock_resp) as mock_post: + hermes_agent.ollama_generate("test prompt") + body = mock_post.call_args[1]["json"] + assert body["model"] == hermes_agent.DEFAULT_MODEL + + def test_uses_timeout_120(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.json.return_value = {"response": "ok"} + mock_resp.raise_for_status = MagicMock() + with patch("hermes_agent.requests.post", return_value=mock_resp) as mock_post: + hermes_agent.ollama_generate("test") + assert mock_post.call_args[1].get("timeout") == 120 + + def test_posts_to_api_generate_endpoint(self): + import hermes_agent + mock_resp = MagicMock() + mock_resp.json.return_value = {"response": "ok"} + mock_resp.raise_for_status = MagicMock() + with patch("hermes_agent.requests.post", return_value=mock_resp) as mock_post: + hermes_agent.ollama_generate("test") + url = mock_post.call_args[0][0] + assert url.endswith("/api/generate") + + def test_returns_error_on_timeout(self): + import hermes_agent + import requests as req_lib + with patch("hermes_agent.requests.post", side_effect=req_lib.exceptions.Timeout()): + result = hermes_agent.ollama_generate("test") + assert result.startswith("[Hermes error]") + + +# --------------------------------------------------------------------------- +# heartbeat() coroutine +# --------------------------------------------------------------------------- + +class TestHeartbeat: + + @pytest.mark.asyncio + async def test_heartbeat_sends_json_message(self): + import hermes_agent + ws = AsyncMock() + # Cancel the infinite loop after first iteration by making sleep raise CancelledError + call_count = 0 + + async def fake_sleep(n): + nonlocal call_count + call_count += 1 + if call_count >= 1: + raise asyncio.CancelledError() + + with patch("hermes_agent.asyncio.sleep", side_effect=fake_sleep): + with pytest.raises(asyncio.CancelledError): + await hermes_agent.heartbeat(ws) + + ws.send.assert_called_once() + sent = json.loads(ws.send.call_args[0][0]) + assert sent["type"] == "HEARTBEAT" + assert sent["agent"] == "hermes" + assert "ts" in sent + + @pytest.mark.asyncio + async def test_heartbeat_breaks_on_send_exception(self): + import hermes_agent + ws = AsyncMock() + ws.send.side_effect = Exception("ws closed") + + async def fast_sleep(n): + pass # Don't actually sleep + + with patch("hermes_agent.asyncio.sleep", side_effect=fast_sleep): + # Should return without raising (breaks out of while loop) + await hermes_agent.heartbeat(ws) + + # send was called once and threw, causing the loop to break + assert ws.send.call_count == 1 + + @pytest.mark.asyncio + async def test_heartbeat_timestamp_is_float(self): + import hermes_agent + ws = AsyncMock() + call_count = 0 + + async def fake_sleep(n): + nonlocal call_count + call_count += 1 + if call_count >= 1: + raise asyncio.CancelledError() + + with patch("hermes_agent.asyncio.sleep", side_effect=fake_sleep): + with pytest.raises(asyncio.CancelledError): + await hermes_agent.heartbeat(ws) + + sent = json.loads(ws.send.call_args[0][0]) + assert isinstance(sent["ts"], float) + + +# --------------------------------------------------------------------------- +# Module-level constants +# --------------------------------------------------------------------------- + +class TestModuleConstants: + + def test_default_gateway_url(self): + env = {"OLLAMA_URL": "http://localhost:11434", "DEFAULT_MODEL": "llama3"} + with patch.dict(os.environ, env, clear=False): + if "hermes_agent" in sys.modules: + del sys.modules["hermes_agent"] + import hermes_agent + assert hermes_agent.GATEWAY_URL == "ws://localhost:8765" + + def test_custom_ollama_url_from_env(self): + with patch.dict(os.environ, {"OLLAMA_URL": "http://myhost:9999"}, clear=False): + if "hermes_agent" in sys.modules: + del sys.modules["hermes_agent"] + import hermes_agent + # OLLAMA_URL used in requests, check the constant + assert hermes_agent.OLLAMA_URL == "http://myhost:9999" + + def test_gateway_token_none_by_default(self): + env = os.environ.copy() + env.pop("GATEWAY_TOKEN", None) + with patch.dict(os.environ, env, clear=True): + if "hermes_agent" in sys.modules: + del sys.modules["hermes_agent"] + import hermes_agent + assert hermes_agent.GATEWAY_TOKEN is None + + def test_custom_default_model(self): + with patch.dict(os.environ, {"DEFAULT_MODEL": "codellama"}, clear=False): + if "hermes_agent" in sys.modules: + del sys.modules["hermes_agent"] + import hermes_agent + assert hermes_agent.DEFAULT_MODEL == "codellama" + + def test_heartbeat_every_is_10(self): + import hermes_agent + assert hermes_agent.HEARTBEAT_EVERY == 10 + + def test_reconnect_delay_is_5(self): + import hermes_agent + assert hermes_agent.RECONNECT_DELAY == 5 \ No newline at end of file diff --git a/agents/tests/test_openclaw_agent.py b/agents/tests/test_openclaw_agent.py new file mode 100644 index 0000000..dd25e35 --- /dev/null +++ b/agents/tests/test_openclaw_agent.py @@ -0,0 +1,650 @@ +""" +Tests for agents/openclaw_agent.py + +Run: + pip install pytest pytest-asyncio requests + cd agents + pytest tests/test_openclaw_agent.py -v +""" + +import asyncio +import json +import os +import platform +import subprocess +import sys +import time +import unittest +from unittest.mock import AsyncMock, MagicMock, patch, call + +import pytest + +# --------------------------------------------------------------------------- +# Path setup +# --------------------------------------------------------------------------- + +AGENTS_DIR = os.path.join(os.path.dirname(__file__), "..") + + +@pytest.fixture(autouse=True) +def _reset_module(): + """Ensure openclaw_agent is freshly imported for each test.""" + if AGENTS_DIR not in sys.path: + sys.path.insert(0, AGENTS_DIR) + yield + sys.modules.pop("openclaw_agent", None) + + +def _import_openclaw(env_overrides=None): + """Import openclaw_agent with controlled environment variables.""" + overrides = env_overrides or {} + env = { + "GATEWAY_URL": overrides.get("GATEWAY_URL", "ws://localhost:8765"), + "OLLAMA_URL": overrides.get("OLLAMA_URL", "http://localhost:11434"), + "OPENCLAW_HTTP": overrides.get("OPENCLAW_HTTP", "http://localhost:3131"), + "DEFAULT_MODEL": overrides.get("DEFAULT_MODEL", "llama3"), + "ENABLE_SHELL_EXEC": overrides.get("ENABLE_SHELL_EXEC", "0"), + "SHELL_ALLOWLIST": overrides.get("SHELL_ALLOWLIST", ""), + } + if "GATEWAY_TOKEN" in overrides: + env["GATEWAY_TOKEN"] = overrides["GATEWAY_TOKEN"] + else: + env.pop("GATEWAY_TOKEN", None) + + sys.modules.pop("openclaw_agent", None) + with patch.dict(os.environ, env, clear=False): + import openclaw_agent + return openclaw_agent + + +# --------------------------------------------------------------------------- +# openclaw_available() +# --------------------------------------------------------------------------- + +class TestOpenclawAvailable: + + def test_returns_true_when_status_200(self): + oc = _import_openclaw() + mock_resp = MagicMock() + mock_resp.status_code = 200 + with patch("openclaw_agent.requests.get", return_value=mock_resp): + assert oc.openclaw_available() is True + + def test_returns_false_when_status_non_200(self): + oc = _import_openclaw() + mock_resp = MagicMock() + mock_resp.status_code = 404 + with patch("openclaw_agent.requests.get", return_value=mock_resp): + assert oc.openclaw_available() is False + + def test_returns_false_on_connection_error(self): + oc = _import_openclaw() + with patch("openclaw_agent.requests.get", side_effect=ConnectionError("refused")): + assert oc.openclaw_available() is False + + def test_calls_health_endpoint(self): + oc = _import_openclaw() + mock_resp = MagicMock() + mock_resp.status_code = 200 + with patch("openclaw_agent.requests.get", return_value=mock_resp) as mock_get: + oc.openclaw_available() + url = mock_get.call_args[0][0] + assert url.endswith("/health") + + def test_uses_timeout_2(self): + oc = _import_openclaw() + mock_resp = MagicMock() + mock_resp.status_code = 200 + with patch("openclaw_agent.requests.get", return_value=mock_resp) as mock_get: + oc.openclaw_available() + assert mock_get.call_args[1].get("timeout") == 2 + + +# --------------------------------------------------------------------------- +# openclaw_task() +# --------------------------------------------------------------------------- + +class TestOpenclawTask: + + def test_returns_result_on_success(self): + oc = _import_openclaw() + mock_resp = MagicMock() + mock_resp.json.return_value = {"result": "task done"} + mock_resp.raise_for_status = MagicMock() + with patch("openclaw_agent.requests.post", return_value=mock_resp): + result = oc.openclaw_task("do something") + assert result == "task done" + + def test_returns_fallback_string_when_no_result_key(self): + oc = _import_openclaw() + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + with patch("openclaw_agent.requests.post", return_value=mock_resp): + result = oc.openclaw_task("do something") + assert result == "(no result)" + + def test_returns_none_on_exception(self): + oc = _import_openclaw() + with patch("openclaw_agent.requests.post", side_effect=ConnectionError("refused")): + result = oc.openclaw_task("do something") + assert result is None + + def test_returns_none_on_http_error(self): + oc = _import_openclaw() + import requests as req_lib + mock_resp = MagicMock() + mock_resp.raise_for_status.side_effect = req_lib.exceptions.HTTPError("500") + with patch("openclaw_agent.requests.post", return_value=mock_resp): + result = oc.openclaw_task("do something") + assert result is None + + def test_posts_task_json_body(self): + oc = _import_openclaw() + mock_resp = MagicMock() + mock_resp.json.return_value = {"result": "ok"} + mock_resp.raise_for_status = MagicMock() + with patch("openclaw_agent.requests.post", return_value=mock_resp) as mock_post: + oc.openclaw_task("list files") + body = mock_post.call_args[1]["json"] + assert body == {"task": "list files"} + + def test_posts_to_task_endpoint(self): + oc = _import_openclaw() + mock_resp = MagicMock() + mock_resp.json.return_value = {"result": "ok"} + mock_resp.raise_for_status = MagicMock() + with patch("openclaw_agent.requests.post", return_value=mock_resp) as mock_post: + oc.openclaw_task("test") + url = mock_post.call_args[0][0] + assert url.endswith("/task") + + +# --------------------------------------------------------------------------- +# system_info() +# --------------------------------------------------------------------------- + +class TestSystemInfo: + + def test_returns_dict_with_required_keys(self): + oc = _import_openclaw() + info = oc.system_info() + required_keys = {"platform", "node", "python", "cwd", "pid", "shell_exec_enabled"} + assert required_keys.issubset(info.keys()) + + def test_pid_is_current_process(self): + oc = _import_openclaw() + info = oc.system_info() + assert info["pid"] == os.getpid() + + def test_cwd_is_current_directory(self): + oc = _import_openclaw() + info = oc.system_info() + assert info["cwd"] == os.getcwd() + + def test_shell_exec_enabled_false_by_default(self): + oc = _import_openclaw({"ENABLE_SHELL_EXEC": "0"}) + info = oc.system_info() + assert info["shell_exec_enabled"] is False + + def test_shell_exec_enabled_true_when_set(self): + oc = _import_openclaw({"ENABLE_SHELL_EXEC": "1"}) + info = oc.system_info() + assert info["shell_exec_enabled"] is True + + def test_python_version_is_string(self): + oc = _import_openclaw() + info = oc.system_info() + assert isinstance(info["python"], str) + assert len(info["python"]) > 0 + + def test_platform_is_string(self): + oc = _import_openclaw() + info = oc.system_info() + assert isinstance(info["platform"], str) + + +# --------------------------------------------------------------------------- +# command_allowed() +# --------------------------------------------------------------------------- + +class TestCommandAllowed: + + def test_returns_true_when_no_allowlist(self): + """When SHELL_ALLOWLIST is unset/empty, all commands pass.""" + with patch.dict(os.environ, {"SHELL_ALLOWLIST": "", "ENABLE_SHELL_EXEC": "0"}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + assert openclaw_agent.SHELL_ALLOWLIST is None + assert openclaw_agent.command_allowed("rm -rf /") is True + + def test_returns_true_when_command_matches_prefix(self): + with patch.dict(os.environ, {"SHELL_ALLOWLIST": "git,ls,docker ps", "ENABLE_SHELL_EXEC": "1"}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + assert openclaw_agent.command_allowed("git status") is True + + def test_returns_true_for_exact_prefix_match(self): + with patch.dict(os.environ, {"SHELL_ALLOWLIST": "ls", "ENABLE_SHELL_EXEC": "1"}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + assert openclaw_agent.command_allowed("ls -la") is True + + def test_returns_false_when_no_prefix_matches(self): + with patch.dict(os.environ, {"SHELL_ALLOWLIST": "git,ls", "ENABLE_SHELL_EXEC": "1"}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + assert openclaw_agent.command_allowed("rm -rf /") is False + + def test_returns_false_for_empty_command_when_allowlist_set(self): + with patch.dict(os.environ, {"SHELL_ALLOWLIST": "git,ls", "ENABLE_SHELL_EXEC": "1"}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + assert openclaw_agent.command_allowed("") is False + + def test_handles_leading_whitespace_in_command(self): + """command_allowed strips leading whitespace from cmd.""" + with patch.dict(os.environ, {"SHELL_ALLOWLIST": "git", "ENABLE_SHELL_EXEC": "1"}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + assert openclaw_agent.command_allowed(" git status") is True + + def test_multiple_prefixes_in_allowlist(self): + with patch.dict(os.environ, {"SHELL_ALLOWLIST": "git,docker ps,ls,dir", "ENABLE_SHELL_EXEC": "1"}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + assert openclaw_agent.command_allowed("docker ps -a") is True + assert openclaw_agent.command_allowed("docker rm container") is False + + def test_allowlist_with_spaces_around_commas(self): + """Allowlist entries should be stripped of surrounding whitespace.""" + with patch.dict(os.environ, {"SHELL_ALLOWLIST": " git , ls ", "ENABLE_SHELL_EXEC": "1"}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + assert openclaw_agent.command_allowed("git status") is True + assert openclaw_agent.command_allowed("ls -la") is True + + +# --------------------------------------------------------------------------- +# run_shell() +# --------------------------------------------------------------------------- + +class TestRunShell: + + def test_returns_blocked_message_when_shell_exec_disabled(self): + oc = _import_openclaw({"ENABLE_SHELL_EXEC": "0"}) + result = oc.run_shell("echo hello") + assert "[blocked]" in result + assert "disabled" in result + + def test_does_not_call_subprocess_when_disabled(self): + oc = _import_openclaw({"ENABLE_SHELL_EXEC": "0"}) + with patch("openclaw_agent.subprocess.run") as mock_run: + oc.run_shell("echo hello") + mock_run.assert_not_called() + + def test_returns_blocked_when_command_not_in_allowlist(self): + with patch.dict(os.environ, {"SHELL_ALLOWLIST": "ls,git", "ENABLE_SHELL_EXEC": "1"}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + result = openclaw_agent.run_shell("rm -rf /tmp/test") + assert "[blocked]" in result + assert "SHELL_ALLOWLIST" in result + + def test_executes_command_when_enabled_and_no_allowlist(self): + with patch.dict(os.environ, {"ENABLE_SHELL_EXEC": "1", "SHELL_ALLOWLIST": ""}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + mock_result = MagicMock() + mock_result.stdout = "hello world\n" + mock_result.stderr = "" + with patch("openclaw_agent.subprocess.run", return_value=mock_result): + result = openclaw_agent.run_shell("echo hello world") + assert result == "hello world" + + def test_combines_stdout_and_stderr(self): + with patch.dict(os.environ, {"ENABLE_SHELL_EXEC": "1", "SHELL_ALLOWLIST": ""}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + mock_result = MagicMock() + mock_result.stdout = "out" + mock_result.stderr = "err" + with patch("openclaw_agent.subprocess.run", return_value=mock_result): + result = openclaw_agent.run_shell("cmd") + assert result == "outerr" + + def test_returns_no_output_when_both_empty(self): + with patch.dict(os.environ, {"ENABLE_SHELL_EXEC": "1", "SHELL_ALLOWLIST": ""}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + mock_result = MagicMock() + mock_result.stdout = "" + mock_result.stderr = "" + with patch("openclaw_agent.subprocess.run", return_value=mock_result): + result = openclaw_agent.run_shell("cmd") + assert result == "(no output)" + + def test_returns_timeout_message_on_timeout(self): + with patch.dict(os.environ, {"ENABLE_SHELL_EXEC": "1", "SHELL_ALLOWLIST": ""}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + with patch("openclaw_agent.subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 30)): + result = openclaw_agent.run_shell("sleep 999") + assert "[timeout]" in result + + def test_returns_error_message_on_exception(self): + with patch.dict(os.environ, {"ENABLE_SHELL_EXEC": "1", "SHELL_ALLOWLIST": ""}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + with patch("openclaw_agent.subprocess.run", side_effect=OSError("permission denied")): + result = openclaw_agent.run_shell("cmd") + assert "[error]" in result + + def test_truncates_output_at_2000_chars(self): + with patch.dict(os.environ, {"ENABLE_SHELL_EXEC": "1", "SHELL_ALLOWLIST": ""}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + mock_result = MagicMock() + mock_result.stdout = "x" * 5000 + mock_result.stderr = "" + with patch("openclaw_agent.subprocess.run", return_value=mock_result): + result = openclaw_agent.run_shell("cmd") + assert len(result) == 2000 + + def test_executes_command_allowed_by_allowlist(self): + with patch.dict(os.environ, {"ENABLE_SHELL_EXEC": "1", "SHELL_ALLOWLIST": "echo"}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + mock_result = MagicMock() + mock_result.stdout = "ok" + mock_result.stderr = "" + with patch("openclaw_agent.subprocess.run", return_value=mock_result) as mock_run: + result = openclaw_agent.run_shell("echo test") + mock_run.assert_called_once() + assert result == "ok" + + +# --------------------------------------------------------------------------- +# ollama_reason() +# --------------------------------------------------------------------------- + +class TestOllamaReason: + + def test_returns_response_text(self): + oc = _import_openclaw() + mock_resp = MagicMock() + mock_resp.json.return_value = {"response": "do this task"} + mock_resp.raise_for_status = MagicMock() + with patch("openclaw_agent.requests.post", return_value=mock_resp): + result = oc.ollama_reason("list the files") + assert result == "do this task" + + def test_returns_error_string_on_failure(self): + oc = _import_openclaw() + with patch("openclaw_agent.requests.post", side_effect=ConnectionError("down")): + result = oc.ollama_reason("task") + assert result.startswith("[Ollama error]") + + def test_prompt_contains_task_text(self): + oc = _import_openclaw() + mock_resp = MagicMock() + mock_resp.json.return_value = {"response": "ok"} + mock_resp.raise_for_status = MagicMock() + with patch("openclaw_agent.requests.post", return_value=mock_resp) as mock_post: + oc.ollama_reason("my unique task string 12345") + body = mock_post.call_args[1]["json"] + assert "my unique task string 12345" in body["prompt"] + + def test_prompt_mentions_shell_disabled_when_exec_off(self): + oc = _import_openclaw({"ENABLE_SHELL_EXEC": "0"}) + mock_resp = MagicMock() + mock_resp.json.return_value = {"response": "ok"} + mock_resp.raise_for_status = MagicMock() + with patch("openclaw_agent.requests.post", return_value=mock_resp) as mock_post: + oc.ollama_reason("task") + body = mock_post.call_args[1]["json"] + assert "disabled" in body["prompt"].lower() or "shell execution is disabled" in body["prompt"] + + def test_prompt_mentions_cmd_format_when_exec_on(self): + oc = _import_openclaw({"ENABLE_SHELL_EXEC": "1"}) + mock_resp = MagicMock() + mock_resp.json.return_value = {"response": "ok"} + mock_resp.raise_for_status = MagicMock() + with patch("openclaw_agent.requests.post", return_value=mock_resp) as mock_post: + oc.ollama_reason("task") + body = mock_post.call_args[1]["json"] + assert "" in body["prompt"] + + def test_uses_specified_model(self): + oc = _import_openclaw() + mock_resp = MagicMock() + mock_resp.json.return_value = {"response": "ok"} + mock_resp.raise_for_status = MagicMock() + with patch("openclaw_agent.requests.post", return_value=mock_resp) as mock_post: + oc.ollama_reason("task", "codellama") + body = mock_post.call_args[1]["json"] + assert body["model"] == "codellama" + + def test_returns_fallback_when_no_response_key(self): + oc = _import_openclaw() + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + with patch("openclaw_agent.requests.post", return_value=mock_resp): + result = oc.ollama_reason("task") + assert result == "(no response)" + + +# --------------------------------------------------------------------------- +# handle_task() +# --------------------------------------------------------------------------- + +class TestHandleTask: + + def test_routes_to_openclaw_app_when_available(self): + oc = _import_openclaw() + with patch("openclaw_agent.openclaw_available", return_value=True), \ + patch("openclaw_agent.openclaw_task", return_value="app result"): + result = oc.handle_task({"task": "do something"}) + assert "[via OpenClaw.app]" in result + assert "app result" in result + + def test_falls_back_to_ollama_when_app_unavailable(self): + oc = _import_openclaw() + with patch("openclaw_agent.openclaw_available", return_value=False), \ + patch("openclaw_agent.ollama_reason", return_value="ollama response"): + result = oc.handle_task({"task": "do something"}) + assert result == "ollama response" + + def test_falls_back_to_ollama_when_app_returns_none(self): + oc = _import_openclaw() + with patch("openclaw_agent.openclaw_available", return_value=True), \ + patch("openclaw_agent.openclaw_task", return_value=None), \ + patch("openclaw_agent.ollama_reason", return_value="ollama fallback"): + result = oc.handle_task({"task": "do something"}) + assert result == "ollama fallback" + + def test_extracts_task_from_dict_payload(self): + oc = _import_openclaw() + captured_task = [] + def fake_ollama(task, model): + captured_task.append(task) + return "ok" + with patch("openclaw_agent.openclaw_available", return_value=False), \ + patch("openclaw_agent.ollama_reason", side_effect=fake_ollama): + oc.handle_task({"task": "my task text", "model": "llama3"}) + assert captured_task[0] == "my task text" + + def test_handles_non_dict_payload(self): + oc = _import_openclaw() + captured_task = [] + def fake_ollama(task, model): + captured_task.append(task) + return "ok" + with patch("openclaw_agent.openclaw_available", return_value=False), \ + patch("openclaw_agent.ollama_reason", side_effect=fake_ollama): + oc.handle_task("a plain string task") + assert captured_task[0] == "a plain string task" + + def test_uses_model_from_payload(self): + oc = _import_openclaw() + captured_model = [] + def fake_ollama(task, model): + captured_model.append(model) + return "ok" + with patch("openclaw_agent.openclaw_available", return_value=False), \ + patch("openclaw_agent.ollama_reason", side_effect=fake_ollama): + oc.handle_task({"task": "t", "model": "mistral"}) + assert captured_model[0] == "mistral" + + def test_executes_cmd_block_when_shell_enabled(self): + with patch.dict(os.environ, {"ENABLE_SHELL_EXEC": "1", "SHELL_ALLOWLIST": ""}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + + shell_response = "Here is the command: echo hello" + with patch("openclaw_agent.openclaw_available", return_value=False), \ + patch("openclaw_agent.ollama_reason", return_value=shell_response), \ + patch("openclaw_agent.run_shell", return_value="hello") as mock_run: + result = openclaw_agent.handle_task({"task": "say hi"}) + mock_run.assert_called_once_with("echo hello") + assert "Command output:" in result + assert "hello" in result + + def test_does_not_execute_cmd_block_when_shell_disabled(self): + oc = _import_openclaw({"ENABLE_SHELL_EXEC": "0"}) + shell_response = "Use this: rm -rf /" + with patch("openclaw_agent.openclaw_available", return_value=False), \ + patch("openclaw_agent.ollama_reason", return_value=shell_response), \ + patch("openclaw_agent.run_shell") as mock_run: + result = oc.handle_task({"task": "delete everything"}) + mock_run.assert_not_called() + # Should just return the raw ollama response + assert result == shell_response + + def test_returns_empty_task_for_missing_key(self): + oc = _import_openclaw() + captured_task = [] + def fake_ollama(task, model): + captured_task.append(task) + return "ok" + with patch("openclaw_agent.openclaw_available", return_value=False), \ + patch("openclaw_agent.ollama_reason", side_effect=fake_ollama): + oc.handle_task({}) # no "task" key + assert captured_task[0] == "" + + +# --------------------------------------------------------------------------- +# Module-level constants +# --------------------------------------------------------------------------- + +class TestModuleConstants: + + def test_shell_exec_disabled_by_default(self): + env = os.environ.copy() + env.pop("ENABLE_SHELL_EXEC", None) + with patch.dict(os.environ, env, clear=True): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + assert openclaw_agent.ENABLE_SHELL_EXEC is False + + def test_shell_exec_enabled_when_env_is_one(self): + with patch.dict(os.environ, {"ENABLE_SHELL_EXEC": "1"}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + assert openclaw_agent.ENABLE_SHELL_EXEC is True + + def test_shell_exec_disabled_for_non_one_values(self): + for val in ["0", "true", "yes", "TRUE", "2"]: + with patch.dict(os.environ, {"ENABLE_SHELL_EXEC": val}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + assert openclaw_agent.ENABLE_SHELL_EXEC is False, f"Failed for ENABLE_SHELL_EXEC={val!r}" + + def test_shell_allowlist_is_none_when_empty(self): + with patch.dict(os.environ, {"SHELL_ALLOWLIST": ""}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + assert openclaw_agent.SHELL_ALLOWLIST is None + + def test_shell_allowlist_parsed_correctly(self): + with patch.dict(os.environ, {"SHELL_ALLOWLIST": "git,ls,docker ps"}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + assert openclaw_agent.SHELL_ALLOWLIST == ["git", "ls", "docker ps"] + + def test_shell_allowlist_strips_whitespace_entries(self): + with patch.dict(os.environ, {"SHELL_ALLOWLIST": " git , ls "}, clear=False): + sys.modules.pop("openclaw_agent", None) + import openclaw_agent + assert "git" in openclaw_agent.SHELL_ALLOWLIST + assert "ls" in openclaw_agent.SHELL_ALLOWLIST + + def test_default_reconnect_delay(self): + oc = _import_openclaw() + assert oc.RECONNECT_DELAY == 10 + + def test_default_heartbeat_every(self): + oc = _import_openclaw() + assert oc.HEARTBEAT_EVERY == 10 + + +# --------------------------------------------------------------------------- +# heartbeat() coroutine +# --------------------------------------------------------------------------- + +class TestHeartbeat: + + @pytest.mark.asyncio + async def test_heartbeat_sends_heartbeat_message(self): + oc = _import_openclaw() + ws = AsyncMock() + call_count = 0 + + async def fake_sleep(n): + nonlocal call_count + call_count += 1 + if call_count >= 1: + raise asyncio.CancelledError() + + with patch("openclaw_agent.asyncio.sleep", side_effect=fake_sleep): + with pytest.raises(asyncio.CancelledError): + await oc.heartbeat(ws) + + ws.send.assert_called_once() + sent = json.loads(ws.send.call_args[0][0]) + assert sent["type"] == "HEARTBEAT" + assert sent["agent"] == "openclaw" + + @pytest.mark.asyncio + async def test_heartbeat_breaks_on_send_exception(self): + oc = _import_openclaw() + ws = AsyncMock() + ws.send.side_effect = Exception("connection closed") + + async def fast_sleep(n): + pass + + with patch("openclaw_agent.asyncio.sleep", side_effect=fast_sleep): + await oc.heartbeat(ws) + + assert ws.send.call_count == 1 + + @pytest.mark.asyncio + async def test_heartbeat_includes_timestamp(self): + oc = _import_openclaw() + ws = AsyncMock() + call_count = 0 + + async def fake_sleep(n): + nonlocal call_count + call_count += 1 + if call_count >= 1: + raise asyncio.CancelledError() + + with patch("openclaw_agent.asyncio.sleep", side_effect=fake_sleep): + with pytest.raises(asyncio.CancelledError): + await oc.heartbeat(ws) + + sent = json.loads(ws.send.call_args[0][0]) + assert isinstance(sent["ts"], float) \ No newline at end of file diff --git a/gateway/package.json b/gateway/package.json new file mode 100644 index 0000000..0a34e85 --- /dev/null +++ b/gateway/package.json @@ -0,0 +1,18 @@ +{ + "name": "crabdeck-gateway", + "version": "2.2.0", + "description": "CrabDeck WebSocket agent bus — bridges UI, Hermes, and OpenClaw", + "main": "server.js", + "type": "commonjs", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js", + "test": "node --test tests/server.test.js" + }, + "dependencies": { + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } +} diff --git a/gateway/tests/server.test.js b/gateway/tests/server.test.js new file mode 100644 index 0000000..d3ad427 --- /dev/null +++ b/gateway/tests/server.test.js @@ -0,0 +1,611 @@ +/** + * Tests for gateway/server.js + * + * Uses Node.js built-in test runner (node:test) — requires Node >= 18. + * + * Run: + * cd gateway + * node --test tests/server.test.js + * + * Strategy: server.js starts listening on load (side-effectful), so we test + * the core business logic functions by inlining their equivalent implementations + * as pure-function unit tests. This covers all decision-making code without + * requiring a live server. + */ + +'use strict' + +const { test, describe } = require('node:test') +const assert = require('node:assert/strict') + +// WebSocket.OPEN constant (value 1 in the ws library) +const WS_OPEN = 1 + +// --------------------------------------------------------------------------- +// requireAuthed() logic +// --------------------------------------------------------------------------- + +describe('requireAuthed()', () => { + function requireAuthed(client, sentMessages) { + if (!client.authed) { + sentMessages.push(JSON.stringify({ + type: 'ERROR', + code: 'UNAUTHENTICATED', + message: 'Send HELLO with a valid token before issuing this command.', + })) + return false + } + return true + } + + test('returns false and sends ERROR for unauthenticated client', () => { + const messages = [] + const result = requireAuthed({ authed: false }, messages) + assert.equal(result, false) + assert.equal(messages.length, 1) + const error = JSON.parse(messages[0]) + assert.equal(error.type, 'ERROR') + assert.equal(error.code, 'UNAUTHENTICATED') + assert.ok(error.message.length > 0) + }) + + test('returns true for authenticated client without sending message', () => { + const messages = [] + const result = requireAuthed({ authed: true }, messages) + assert.equal(result, true) + assert.equal(messages.length, 0) + }) + + test('error message mentions HELLO', () => { + const messages = [] + requireAuthed({ authed: false }, messages) + const error = JSON.parse(messages[0]) + assert.ok(error.message.includes('HELLO')) + }) +}) + +// --------------------------------------------------------------------------- +// Role assignment from HELLO client field +// --------------------------------------------------------------------------- + +describe('role assignment from HELLO client field', () => { + function mapRole(clientName) { + return clientName === 'crabdeck-ui' ? 'ui' + : clientName === 'openclaw' ? 'openclaw' + : clientName === 'hermes' ? 'hermes' + : clientName === 'orchestrator' ? 'orchestrator' + : 'unknown' + } + + test('maps "crabdeck-ui" to "ui"', () => assert.equal(mapRole('crabdeck-ui'), 'ui')) + test('maps "openclaw" to "openclaw"', () => assert.equal(mapRole('openclaw'), 'openclaw')) + test('maps "hermes" to "hermes"', () => assert.equal(mapRole('hermes'), 'hermes')) + test('maps "orchestrator" to "orchestrator"', () => assert.equal(mapRole('orchestrator'), 'orchestrator')) + test('maps unknown name to "unknown"', () => assert.equal(mapRole('bad-client'), 'unknown')) + test('maps empty string to "unknown"', () => assert.equal(mapRole(''), 'unknown')) + test('is case-sensitive (Hermes ≠ hermes)', () => assert.equal(mapRole('Hermes'), 'unknown')) +}) + +// --------------------------------------------------------------------------- +// Token authentication logic +// --------------------------------------------------------------------------- + +describe('token authentication logic', () => { + function checkToken(gatewayToken, providedToken) { + if (gatewayToken && providedToken !== gatewayToken) { + return { authed: false, code: 'BAD_TOKEN', message: 'Invalid gateway token' } + } + return { authed: true } + } + + test('accepts any token when gateway has no token configured', () => + assert.deepEqual(checkToken(null, 'anything'), { authed: true })) + + test('accepts correct token', () => + assert.deepEqual(checkToken('secret', 'secret'), { authed: true })) + + test('rejects wrong token', () => { + const r = checkToken('secret', 'wrong') + assert.equal(r.authed, false) + assert.equal(r.code, 'BAD_TOKEN') + }) + + test('rejects missing token when gateway requires one', () => { + const r = checkToken('secret', undefined) + assert.equal(r.authed, false) + assert.equal(r.code, 'BAD_TOKEN') + }) + + test('rejects null token when gateway requires one', () => { + const r = checkToken('secret', null) + assert.equal(r.authed, false) + }) + + test('no-token gateway: accepts undefined token', () => + assert.equal(checkToken(null, undefined).authed, true)) + + test('error message indicates bad token', () => { + const r = checkToken('secret', 'wrong') + assert.ok(r.message.toLowerCase().includes('token')) + }) +}) + +// --------------------------------------------------------------------------- +// Role lock — no re-HELLO identity swap +// --------------------------------------------------------------------------- + +describe('role lock (no re-HELLO swap)', () => { + function handleHello(client, msg, gatewayToken) { + // Role can only be set once per connection + if (client.role !== 'unknown') { + return { type: 'ACK', role: client.role } + } + if (gatewayToken && msg.token !== gatewayToken) { + return { type: 'ERROR', code: 'BAD_TOKEN' } + } + const role = msg.client === 'hermes' ? 'hermes' + : msg.client === 'openclaw' ? 'openclaw' + : msg.client === 'crabdeck-ui' ? 'ui' + : 'unknown' + client.role = role + client.authed = true + return { type: 'ACK', role } + } + + test('first HELLO sets role', () => { + const client = { role: 'unknown', authed: false } + const resp = handleHello(client, { client: 'hermes', token: null }, null) + assert.equal(resp.type, 'ACK') + assert.equal(resp.role, 'hermes') + assert.equal(client.role, 'hermes') + }) + + test('second HELLO returns ACK with existing role without changing it', () => { + const client = { role: 'hermes', authed: true } + const resp = handleHello(client, { client: 'openclaw' }, null) + assert.equal(resp.type, 'ACK') + assert.equal(resp.role, 'hermes') // unchanged + assert.equal(client.role, 'hermes') + }) + + test('bad token on first HELLO returns ERROR', () => { + const client = { role: 'unknown', authed: false } + const resp = handleHello(client, { client: 'hermes', token: 'wrong' }, 'secret') + assert.equal(resp.type, 'ERROR') + assert.equal(resp.code, 'BAD_TOKEN') + }) +}) + +// --------------------------------------------------------------------------- +// Origin allow-list verification +// --------------------------------------------------------------------------- + +describe('origin allow-list (verifyClient)', () => { + function isOriginAllowed(origin, allowedOrigins) { + if (!origin) return true // agent processes — no Origin header + if (!allowedOrigins.length) return true + return allowedOrigins.includes(origin) + } + + const allowed = ['http://localhost:5173', 'https://app.example.com'] + + test('allows connection with no Origin header (agent process)', () => + assert.equal(isOriginAllowed(undefined, allowed), true)) + + test('allows connection from exact allowed origin', () => + assert.equal(isOriginAllowed('http://localhost:5173', allowed), true)) + + test('rejects connection from disallowed origin', () => + assert.equal(isOriginAllowed('https://evil.example.com', allowed), false)) + + test('allows any origin when allow-list is empty', () => + assert.equal(isOriginAllowed('https://evil.example.com', []), true)) + + test('rejects partial-match origin (must be exact)', () => + assert.equal(isOriginAllowed('http://localhost:51730', allowed), false)) + + test('rejects null origin when allow-list is configured', () => + assert.equal(isOriginAllowed(null, allowed), false)) +}) + +// --------------------------------------------------------------------------- +// ALLOWED_ORIGINS env parsing +// --------------------------------------------------------------------------- + +describe('ALLOWED_ORIGINS env parsing', () => { + function parseAllowedOrigins(envValue) { + return (envValue || 'http://localhost:5173') + .split(',') + .map(s => s.trim()) + .filter(Boolean) + } + + test('uses default origin when env not set', () => + assert.deepEqual(parseAllowedOrigins(undefined), ['http://localhost:5173'])) + + test('parses multiple comma-separated origins', () => + assert.deepEqual( + parseAllowedOrigins('http://localhost:5173,https://app.example.com'), + ['http://localhost:5173', 'https://app.example.com'] + )) + + test('trims whitespace around each origin', () => { + const result = parseAllowedOrigins(' http://localhost:5173 , https://app.example.com ') + assert.deepEqual(result, ['http://localhost:5173', 'https://app.example.com']) + }) + + test('filters empty entries from double-comma', () => { + const result = parseAllowedOrigins('http://localhost:5173,,https://app.example.com') + assert.equal(result.length, 2) + }) + + test('single origin parses to one-element array', () => + assert.deepEqual(parseAllowedOrigins('http://localhost:3000'), ['http://localhost:3000'])) +}) + +// --------------------------------------------------------------------------- +// broadcast() logic +// --------------------------------------------------------------------------- + +describe('broadcast()', () => { + function makeWs(readyState = WS_OPEN) { + const messages = [] + return { readyState, send: m => messages.push(m), messages } + } + + function broadcast(clients, msg, excludeId = null) { + const raw = JSON.stringify(msg) + for (const [id, c] of clients) { + if (id !== excludeId && c.ws.readyState === WS_OPEN) { + c.ws.send(raw) + } + } + } + + test('sends to all open clients', () => { + const ws1 = makeWs(); const ws2 = makeWs() + const clients = new Map([['c1', { ws: ws1 }], ['c2', { ws: ws2 }]]) + broadcast(clients, { type: 'TEST' }) + assert.equal(ws1.messages.length, 1) + assert.equal(ws2.messages.length, 1) + }) + + test('excludes the specified client id', () => { + const ws1 = makeWs(); const ws2 = makeWs() + const clients = new Map([['c1', { ws: ws1 }], ['c2', { ws: ws2 }]]) + broadcast(clients, { type: 'TEST' }, 'c1') + assert.equal(ws1.messages.length, 0) + assert.equal(ws2.messages.length, 1) + }) + + test('skips clients that are not OPEN', () => { + const wsClosed = makeWs(3) // CLOSED + const wsOpen = makeWs(WS_OPEN) + const clients = new Map([['c1', { ws: wsClosed }], ['c2', { ws: wsOpen }]]) + broadcast(clients, { type: 'TEST' }) + assert.equal(wsClosed.messages.length, 0) + assert.equal(wsOpen.messages.length, 1) + }) + + test('sends nothing to empty client map', () => { + broadcast(new Map(), { type: 'TEST' }) // should not throw + }) + + test('serialises message correctly', () => { + const ws = makeWs() + broadcast(new Map([['c1', { ws }]]), { type: 'PING', value: 42 }) + assert.deepEqual(JSON.parse(ws.messages[0]), { type: 'PING', value: 42 }) + }) + + test('multiple excludes still broadcasts to others', () => { + const ws1 = makeWs(); const ws2 = makeWs(); const ws3 = makeWs() + const clients = new Map([['c1', { ws: ws1 }], ['c2', { ws: ws2 }], ['c3', { ws: ws3 }]]) + broadcast(clients, { type: 'T' }, 'c1') + assert.equal(ws1.messages.length, 0) + assert.equal(ws2.messages.length, 1) + assert.equal(ws3.messages.length, 1) + }) +}) + +// --------------------------------------------------------------------------- +// sendTo() logic +// --------------------------------------------------------------------------- + +describe('sendTo()', () => { + function makeWs() { + const messages = [] + return { readyState: WS_OPEN, send: m => messages.push(m), messages } + } + + function sendTo(clients, role, msg) { + for (const [, c] of clients) { + if (c.role === role && c.authed && c.ws.readyState === WS_OPEN) { + c.ws.send(JSON.stringify(msg)) + } + } + } + + test('sends to all authed clients with matching role', () => { + const ws1 = makeWs(); const ws2 = makeWs(); const ws3 = makeWs() + const clients = new Map([ + ['c1', { role: 'ui', authed: true, ws: ws1 }], + ['c2', { role: 'ui', authed: true, ws: ws2 }], + ['c3', { role: 'hermes', authed: true, ws: ws3 }], + ]) + sendTo(clients, 'ui', { type: 'MSG' }) + assert.equal(ws1.messages.length, 1) + assert.equal(ws2.messages.length, 1) + assert.equal(ws3.messages.length, 0) + }) + + test('does not send to unauthenticated clients', () => { + const ws = makeWs() + const clients = new Map([['c1', { role: 'hermes', authed: false, ws }]]) + sendTo(clients, 'hermes', { type: 'PROMPT' }) + assert.equal(ws.messages.length, 0) + }) + + test('does not send to clients with wrong role', () => { + const ws = makeWs() + const clients = new Map([['c1', { role: 'openclaw', authed: true, ws }]]) + sendTo(clients, 'hermes', { type: 'PROMPT' }) + assert.equal(ws.messages.length, 0) + }) + + test('sends nothing when no clients in map', () => { + sendTo(new Map(), 'ui', { type: 'T' }) // should not throw + }) +}) + +// --------------------------------------------------------------------------- +// HERMES_RESPONSE routing — only hermes role may send, only ui receives +// --------------------------------------------------------------------------- + +describe('HERMES_RESPONSE routing rules', () => { + function handleHermesResponse(client) { + // Returns whether the message should be routed + return client.authed && client.role === 'hermes' + } + + test('allows hermes-role authed client to send response', () => + assert.equal(handleHermesResponse({ authed: true, role: 'hermes' }), true)) + + test('blocks unauthenticated hermes client', () => + assert.equal(handleHermesResponse({ authed: false, role: 'hermes' }), false)) + + test('blocks ui-role client from sending hermes response', () => + assert.equal(handleHermesResponse({ authed: true, role: 'ui' }), false)) + + test('blocks openclaw-role client from sending hermes response', () => + assert.equal(handleHermesResponse({ authed: true, role: 'openclaw' }), false)) +}) + +// --------------------------------------------------------------------------- +// TASK_RESULT routing — only openclaw role may send +// --------------------------------------------------------------------------- + +describe('TASK_RESULT routing rules', () => { + function handleTaskResult(client) { + return client.authed && client.role === 'openclaw' + } + + test('allows openclaw-role authed client to send task result', () => + assert.equal(handleTaskResult({ authed: true, role: 'openclaw' }), true)) + + test('blocks unauthenticated openclaw client', () => + assert.equal(handleTaskResult({ authed: false, role: 'openclaw' }), false)) + + test('blocks hermes from sending task result', () => + assert.equal(handleTaskResult({ authed: true, role: 'hermes' }), false)) + + test('blocks ui from sending task result', () => + assert.equal(handleTaskResult({ authed: true, role: 'ui' }), false)) +}) + +// --------------------------------------------------------------------------- +// Heartbeat watchdog logic +// --------------------------------------------------------------------------- + +describe('heartbeat watchdog', () => { + function runWatchdog(clients, agentStatus, now, threshold = 20_000) { + const broadcasts = [] + for (const [, c] of clients) { + if (c.role !== 'ui' && c.role !== 'unknown') { + if (now - c.lastSeen > threshold && agentStatus[c.role] === 'running') { + agentStatus[c.role] = 'missed_heartbeat' + broadcasts.push({ type: 'AGENT_STATUS', agent: c.role, status: 'missed_heartbeat' }) + } + } + } + return broadcasts + } + + test('marks running agent as missed_heartbeat after silence > 20s', () => { + const now = Date.now() + const clients = new Map([['c1', { role: 'hermes', lastSeen: now - 25_000 }]]) + const agentStatus = { hermes: 'running' } + const broadcasts = runWatchdog(clients, agentStatus, now) + assert.equal(agentStatus.hermes, 'missed_heartbeat') + assert.equal(broadcasts.length, 1) + assert.equal(broadcasts[0].agent, 'hermes') + assert.equal(broadcasts[0].status, 'missed_heartbeat') + }) + + test('does not mark agent within the 20s threshold', () => { + const now = Date.now() + const clients = new Map([['c1', { role: 'hermes', lastSeen: now - 5_000 }]]) + const agentStatus = { hermes: 'running' } + runWatchdog(clients, agentStatus, now) + assert.equal(agentStatus.hermes, 'running') + }) + + test('ignores ui clients', () => { + const now = Date.now() + const clients = new Map([['c1', { role: 'ui', lastSeen: now - 999_999 }]]) + const agentStatus = {} + const broadcasts = runWatchdog(clients, agentStatus, now) + assert.equal(broadcasts.length, 0) + }) + + test('ignores unknown-role clients', () => { + const now = Date.now() + const clients = new Map([['c1', { role: 'unknown', lastSeen: now - 999_999 }]]) + const broadcasts = runWatchdog(clients, {}, now) + assert.equal(broadcasts.length, 0) + }) + + test('does not re-broadcast agent already at missed_heartbeat', () => { + const now = Date.now() + const clients = new Map([['c1', { role: 'hermes', lastSeen: now - 999_999 }]]) + const agentStatus = { hermes: 'missed_heartbeat' } + const broadcasts = runWatchdog(clients, agentStatus, now) + assert.equal(broadcasts.length, 0) + }) + + test('marks multiple stale agents', () => { + const now = Date.now() + const clients = new Map([ + ['c1', { role: 'hermes', lastSeen: now - 30_000 }], + ['c2', { role: 'openclaw', lastSeen: now - 30_000 }], + ]) + const agentStatus = { hermes: 'running', openclaw: 'running' } + const broadcasts = runWatchdog(clients, agentStatus, now) + assert.equal(broadcasts.length, 2) + assert.equal(agentStatus.hermes, 'missed_heartbeat') + assert.equal(agentStatus.openclaw, 'missed_heartbeat') + }) + + test('exact threshold boundary: 20000ms is NOT stale', () => { + const now = Date.now() + const clients = new Map([['c1', { role: 'hermes', lastSeen: now - 20_000 }]]) + const agentStatus = { hermes: 'running' } + runWatchdog(clients, agentStatus, now) + // 20000 > 20000 is false — should still be running + assert.equal(agentStatus.hermes, 'running') + }) +}) + +// --------------------------------------------------------------------------- +// agentStatus initial state +// --------------------------------------------------------------------------- + +describe('agentStatus initial values', () => { + const agentStatus = { + openclaw: 'offline', + hermes: 'offline', + crabdeck: 'running', + } + + test('openclaw starts offline', () => assert.equal(agentStatus.openclaw, 'offline')) + test('hermes starts offline', () => assert.equal(agentStatus.hermes, 'offline')) + test('crabdeck starts running', () => assert.equal(agentStatus.crabdeck, 'running')) +}) + +// --------------------------------------------------------------------------- +// Connection close logic — agent status update on disconnect +// --------------------------------------------------------------------------- + +describe('close handler — agent status on disconnect', () => { + function handleClose(clientRole, agentStatus) { + const broadcasts = [] + if (clientRole === 'openclaw' || clientRole === 'hermes') { + agentStatus[clientRole] = 'offline' + broadcasts.push({ type: 'AGENT_STATUS', agent: clientRole, status: 'offline' }) + } + return broadcasts + } + + test('openclaw disconnect marks it offline', () => { + const status = { openclaw: 'running', hermes: 'running' } + const bcast = handleClose('openclaw', status) + assert.equal(status.openclaw, 'offline') + assert.equal(bcast.length, 1) + assert.equal(bcast[0].status, 'offline') + }) + + test('hermes disconnect marks it offline', () => { + const status = { openclaw: 'running', hermes: 'running' } + handleClose('hermes', status) + assert.equal(status.hermes, 'offline') + }) + + test('ui disconnect does NOT change agentStatus', () => { + const status = { openclaw: 'running', hermes: 'running' } + const bcast = handleClose('ui', status) + assert.equal(bcast.length, 0) + assert.equal(status.openclaw, 'running') + }) + + test('unknown-role disconnect does NOT change agentStatus', () => { + const status = { openclaw: 'running' } + const bcast = handleClose('unknown', status) + assert.equal(bcast.length, 0) + }) +}) + +// --------------------------------------------------------------------------- +// HELLO with token — authed flag +// --------------------------------------------------------------------------- + +describe('authed flag on connection', () => { + // When GATEWAY_TOKEN is falsy: authed = !GATEWAY_TOKEN = true (open mode) + // When GATEWAY_TOKEN is set: authed = false initially, set to true after valid HELLO + + test('client starts authed in open mode (no token)', () => { + const GATEWAY_TOKEN = null + const authed = !GATEWAY_TOKEN + assert.equal(authed, true) + }) + + test('client starts unauthed when token is required', () => { + const GATEWAY_TOKEN = 'secret' + const authed = !GATEWAY_TOKEN + assert.equal(authed, false) + }) +}) + +// --------------------------------------------------------------------------- +// HEARTBEAT message handling +// --------------------------------------------------------------------------- + +describe('HEARTBEAT handling', () => { + function handleHeartbeat(client, agentStatus) { + if (!client.authed) return { allowed: false } + const role = client.role + const broadcasts = [] + if (role && agentStatus[role] !== undefined) { + agentStatus[role] = 'running' + broadcasts.push({ type: 'AGENT_STATUS', agent: role, status: 'running' }) + } + return { allowed: true, broadcasts, ack: { type: 'HEARTBEAT_ACK' } } + } + + test('rejects heartbeat from unauthenticated client', () => { + const client = { authed: false, role: 'hermes' } + const result = handleHeartbeat(client, { hermes: 'offline' }) + assert.equal(result.allowed, false) + }) + + test('accepts heartbeat from authenticated agent', () => { + const client = { authed: true, role: 'hermes' } + const agentStatus = { hermes: 'offline' } + const result = handleHeartbeat(client, agentStatus) + assert.equal(result.allowed, true) + assert.equal(agentStatus.hermes, 'running') + }) + + test('sends HEARTBEAT_ACK on success', () => { + const client = { authed: true, role: 'openclaw' } + const agentStatus = { openclaw: 'running' } + const result = handleHeartbeat(client, agentStatus) + assert.equal(result.ack.type, 'HEARTBEAT_ACK') + }) + + test('broadcasts AGENT_STATUS update', () => { + const client = { authed: true, role: 'openclaw' } + const agentStatus = { openclaw: 'missed_heartbeat' } + const result = handleHeartbeat(client, agentStatus) + assert.equal(result.broadcasts.length, 1) + assert.equal(result.broadcasts[0].status, 'running') + }) +}) \ No newline at end of file diff --git a/orchestrator/pytest.ini b/orchestrator/pytest.ini new file mode 100644 index 0000000..df9d25e --- /dev/null +++ b/orchestrator/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +asyncio_mode = auto \ No newline at end of file diff --git a/orchestrator/tests/__init__.py b/orchestrator/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orchestrator/tests/test_main.py b/orchestrator/tests/test_main.py new file mode 100644 index 0000000..4570ada --- /dev/null +++ b/orchestrator/tests/test_main.py @@ -0,0 +1,588 @@ +""" +Tests for orchestrator/main.py + +Run: + pip install pytest pytest-asyncio httpx fastapi + cd orchestrator + pytest tests/test_main.py -v +""" + +import asyncio +import os +import sys +import time +import uuid +from typing import List +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Path setup — add orchestrator/ to sys.path so we can import main +# --------------------------------------------------------------------------- + +ORCHESTRATOR_DIR = os.path.join(os.path.dirname(__file__), "..") +if ORCHESTRATOR_DIR not in sys.path: + sys.path.insert(0, ORCHESTRATOR_DIR) + + +# --------------------------------------------------------------------------- +# Helpers — import main with mocked heavy deps +# --------------------------------------------------------------------------- + +def _import_main(env_overrides=None): + """Import (or re-import) orchestrator/main.py with controlled env.""" + overrides = env_overrides or {} + env = { + "GATEWAY_URL": overrides.get("GATEWAY_URL", "ws://localhost:8765"), + "ALLOWED_ORIGINS": overrides.get("ALLOWED_ORIGINS", "http://localhost:5173"), + } + if "GATEWAY_TOKEN" in overrides: + env["GATEWAY_TOKEN"] = overrides["GATEWAY_TOKEN"] + else: + env.pop("GATEWAY_TOKEN", None) + + sys.modules.pop("main", None) + with patch.dict(os.environ, env, clear=False): + import main as m + return m + + +@pytest.fixture(autouse=True) +def _reset_state(): + """Reset module-level state before each test.""" + sys.modules.pop("main", None) + yield + # Clean up imported module + sys.modules.pop("main", None) + + +# --------------------------------------------------------------------------- +# Import + fixtures for FastAPI TestClient +# --------------------------------------------------------------------------- + +@pytest.fixture() +def client(): + """Create a FastAPI TestClient with agents pre-seeded. + + The lifespan launches listen_gateway() which tries to connect to the + WebSocket gateway (not running during tests). We patch websockets.connect + so the coroutine raises immediately, causing listen_gateway to log an + event and retry — but the task is cancelled during TestClient shutdown + so tests complete quickly. + """ + from fastapi.testclient import TestClient + import main + + async def _never_connect(*args, **kwargs): + raise ConnectionRefusedError("gateway not available during tests") + + # Patch at the module level so listen_gateway() sees the mock + with patch("main.websockets.connect", side_effect=_never_connect): + # Reset module state + main.agents.clear() + main.events.clear() + main._heartbeat_thread_started = False + main.seed_agents() + + with TestClient(main.app) as c: + yield c + + main.agents.clear() + main.events.clear() + + +# --------------------------------------------------------------------------- +# add_event() +# --------------------------------------------------------------------------- + +class TestAddEvent: + + def test_appends_event_to_list(self): + import main + main.events.clear() + main.add_event("TEST", "test message", None) + assert len(main.events) == 1 + + def test_event_has_correct_type_and_message(self): + import main + main.events.clear() + main.add_event("MY_TYPE", "my message", "agent1") + evt = main.events[-1] + assert evt.type == "MY_TYPE" + assert evt.message == "my message" + assert evt.agent_id == "agent1" + + def test_event_has_unique_id(self): + import main + main.events.clear() + main.add_event("T1", "msg1", None) + main.add_event("T2", "msg2", None) + ids = [e.id for e in main.events] + assert ids[0] != ids[1] + + def test_event_has_timestamp(self): + import main + main.events.clear() + before = time.time() + main.add_event("T", "m", None) + after = time.time() + evt = main.events[-1] + assert before <= evt.timestamp <= after + + def test_enforces_max_events_limit(self): + import main + main.events.clear() + # Fill to MAX_EVENTS + for i in range(main.MAX_EVENTS + 10): + main.add_event("T", f"msg {i}", None) + assert len(main.events) <= main.MAX_EVENTS + + def test_drops_oldest_events_when_over_limit(self): + import main + main.events.clear() + # Fill exactly to limit + for i in range(main.MAX_EVENTS): + main.add_event("T", f"msg {i}", None) + # Add one more — should drop the oldest + main.add_event("T", "newest message", None) + assert len(main.events) == main.MAX_EVENTS + # The newest message should still be there + assert main.events[-1].message == "newest message" + + def test_agent_id_none_allowed(self): + import main + main.events.clear() + main.add_event("SYSTEM", "system event", None) + assert main.events[-1].agent_id is None + + +# --------------------------------------------------------------------------- +# seed_agents() +# --------------------------------------------------------------------------- + +class TestSeedAgents: + + def test_creates_three_agents(self): + import main + main.agents.clear() + main.events.clear() + main.seed_agents() + assert len(main.agents) == 3 + + def test_creates_expected_agent_ids(self): + import main + main.agents.clear() + main.events.clear() + main.seed_agents() + assert "crabdeck" in main.agents + assert "openclaw" in main.agents + assert "hermes" in main.agents + + def test_agents_start_offline(self): + import main + main.agents.clear() + main.events.clear() + main.seed_agents() + for agent in main.agents.values(): + assert agent.status == "offline" + + def test_agents_have_correct_names(self): + import main + main.agents.clear() + main.events.clear() + main.seed_agents() + assert main.agents["crabdeck"].name == "CrabDeck Gateway" + assert main.agents["openclaw"].name == "OpenClaw Sovereign" + assert main.agents["hermes"].name == "Hermes Messenger" + + def test_agents_belong_to_crabdeck_team(self): + import main + main.agents.clear() + main.events.clear() + main.seed_agents() + for agent in main.agents.values(): + assert agent.team == "crabdeck" + + def test_agents_have_last_heartbeat_set(self): + import main + main.agents.clear() + main.events.clear() + before = time.time() + main.seed_agents() + after = time.time() + for agent in main.agents.values(): + assert before <= agent.last_heartbeat <= after + + def test_adds_seeded_event(self): + import main + main.agents.clear() + main.events.clear() + main.seed_agents() + event_types = [e.type for e in main.events] + assert "SYSTEM" in event_types + + +# --------------------------------------------------------------------------- +# heartbeat_loop() (unit test of the logic — not the threading) +# --------------------------------------------------------------------------- + +class TestHeartbeatLoop: + + def test_marks_agent_as_error_after_timeout(self): + import main + main.agents.clear() + main.events.clear() + main.seed_agents() + # Force hermes into running status with an old heartbeat + main.agents["hermes"].status = "running" + main.agents["hermes"].last_heartbeat = time.time() - (main.HEARTBEAT_TIMEOUT + 5) + + # Run one iteration of the loop logic directly (not via thread) + now = time.time() + for agent_id, agent in list(main.agents.items()): + if agent.status == "running" and (now - agent.last_heartbeat) > main.HEARTBEAT_TIMEOUT: + agent.status = "error" + agent.error_message = "Heartbeat missed" + main.add_event("HEARTBEAT_MISSED", f"{agent.name} missed heartbeat", agent_id) + + assert main.agents["hermes"].status == "error" + assert main.agents["hermes"].error_message == "Heartbeat missed" + + def test_does_not_mark_offline_agents_as_error(self): + import main + main.agents.clear() + main.events.clear() + main.seed_agents() + # hermes is offline (default), with an old-looking heartbeat + main.agents["hermes"].status = "offline" + main.agents["hermes"].last_heartbeat = time.time() - 9999 + + now = time.time() + for agent_id, agent in list(main.agents.items()): + if agent.status == "running" and (now - agent.last_heartbeat) > main.HEARTBEAT_TIMEOUT: + agent.status = "error" + + # Should still be offline — was not "running" + assert main.agents["hermes"].status == "offline" + + def test_running_agent_within_timeout_not_marked_error(self): + import main + main.agents.clear() + main.events.clear() + main.seed_agents() + main.agents["hermes"].status = "running" + main.agents["hermes"].last_heartbeat = time.time() # just now + + now = time.time() + for agent_id, agent in list(main.agents.items()): + if agent.status == "running" and (now - agent.last_heartbeat) > main.HEARTBEAT_TIMEOUT: + agent.status = "error" + + assert main.agents["hermes"].status == "running" + + +# --------------------------------------------------------------------------- +# REST API — /health +# --------------------------------------------------------------------------- + +class TestHealthEndpoint: + + def test_returns_200(self, client): + resp = client.get("/health") + assert resp.status_code == 200 + + def test_returns_status_ok(self, client): + resp = client.get("/health") + data = resp.json() + assert data["status"] == "ok" + + def test_returns_agent_count(self, client): + resp = client.get("/health") + data = resp.json() + assert data["agent_count"] == 3 # seeded 3 agents + + def test_returns_uptime_field(self, client): + resp = client.get("/health") + data = resp.json() + assert "uptime" in data + assert isinstance(data["uptime"], float) + + +# --------------------------------------------------------------------------- +# REST API — /agents +# --------------------------------------------------------------------------- + +class TestListAgentsEndpoint: + + def test_returns_200(self, client): + resp = client.get("/agents") + assert resp.status_code == 200 + + def test_returns_list_of_agents(self, client): + resp = client.get("/agents") + data = resp.json() + assert isinstance(data, list) + assert len(data) == 3 + + def test_agents_have_required_fields(self, client): + resp = client.get("/agents") + data = resp.json() + required_fields = {"id", "name", "status", "last_heartbeat"} + for agent in data: + assert required_fields.issubset(agent.keys()) + + def test_agent_ids_present(self, client): + resp = client.get("/agents") + data = resp.json() + ids = {a["id"] for a in data} + assert "crabdeck" in ids + assert "openclaw" in ids + assert "hermes" in ids + + +# --------------------------------------------------------------------------- +# REST API — /agents/{agent_id} +# --------------------------------------------------------------------------- + +class TestGetAgentEndpoint: + + def test_returns_200_for_valid_agent(self, client): + resp = client.get("/agents/hermes") + assert resp.status_code == 200 + + def test_returns_agent_data(self, client): + resp = client.get("/agents/hermes") + data = resp.json() + assert data["id"] == "hermes" + assert data["name"] == "Hermes Messenger" + + def test_returns_404_for_unknown_agent(self, client): + resp = client.get("/agents/does-not-exist") + assert resp.status_code == 404 + + def test_404_detail_message(self, client): + resp = client.get("/agents/nonexistent") + data = resp.json() + assert "not found" in data["detail"].lower() + + def test_returns_openclaw_agent(self, client): + resp = client.get("/agents/openclaw") + assert resp.status_code == 200 + assert resp.json()["id"] == "openclaw" + + def test_returns_crabdeck_agent(self, client): + resp = client.get("/agents/crabdeck") + assert resp.status_code == 200 + assert resp.json()["id"] == "crabdeck" + + +# --------------------------------------------------------------------------- +# REST API — /agents/{agent_id}/restart +# --------------------------------------------------------------------------- + +class TestRestartAgentEndpoint: + + def test_returns_200_for_valid_agent(self, client): + resp = client.post("/agents/hermes/restart") + assert resp.status_code == 200 + + def test_sets_status_to_running(self, client): + import main + main.agents["hermes"].status = "error" + resp = client.post("/agents/hermes/restart") + data = resp.json() + assert data["status"] == "running" + + def test_clears_error_message(self, client): + import main + main.agents["hermes"].status = "error" + main.agents["hermes"].error_message = "Heartbeat missed" + resp = client.post("/agents/hermes/restart") + data = resp.json() + assert data["error_message"] is None + + def test_updates_last_heartbeat(self, client): + import main + old_hb = time.time() - 999 + main.agents["hermes"].last_heartbeat = old_hb + before = time.time() + resp = client.post("/agents/hermes/restart") + data = resp.json() + assert data["last_heartbeat"] >= before + + def test_returns_404_for_unknown_agent(self, client): + resp = client.post("/agents/ghost/restart") + assert resp.status_code == 404 + + def test_adds_restart_event(self, client): + import main + main.events.clear() + client.post("/agents/hermes/restart") + event_types = [e.type for e in main.events] + assert "AGENT_RESTARTED" in event_types + + def test_restart_offline_agent(self, client): + import main + main.agents["openclaw"].status = "offline" + resp = client.post("/agents/openclaw/restart") + assert resp.status_code == 200 + assert resp.json()["status"] == "running" + + +# --------------------------------------------------------------------------- +# REST API — /events +# --------------------------------------------------------------------------- + +class TestGetEventsEndpoint: + + def test_returns_200(self, client): + resp = client.get("/events") + assert resp.status_code == 200 + + def test_returns_list(self, client): + resp = client.get("/events") + assert isinstance(resp.json(), list) + + def test_returns_events_up_to_limit(self, client): + import main + main.events.clear() + for i in range(50): + main.add_event("T", f"msg {i}", None) + resp = client.get("/events?limit=10") + data = resp.json() + assert len(data) == 10 + + def test_default_limit_is_100(self, client): + import main + main.events.clear() + for i in range(150): + main.add_event("T", f"msg {i}", None) + resp = client.get("/events") + data = resp.json() + assert len(data) == 100 + + def test_returns_latest_events(self, client): + import main + main.events.clear() + for i in range(20): + main.add_event("T", f"msg {i}", None) + resp = client.get("/events?limit=5") + data = resp.json() + messages = [e["message"] for e in data] + assert "msg 19" in messages # last added should be in results + + def test_events_have_required_fields(self, client): + import main + main.events.clear() + main.add_event("TEST", "test msg", "agent1") + resp = client.get("/events") + data = resp.json() + evt = data[-1] + assert "id" in evt + assert "timestamp" in evt + assert "type" in evt + assert "message" in evt + + def test_returns_empty_list_when_no_events(self, client): + import main + main.events.clear() + resp = client.get("/events") + assert resp.json() == [] + + +# --------------------------------------------------------------------------- +# CORS configuration +# --------------------------------------------------------------------------- + +class TestCORSConfig: + + def test_default_allowed_origin(self): + env = os.environ.copy() + env.pop("ALLOWED_ORIGINS", None) + with patch.dict(os.environ, env, clear=True): + sys.modules.pop("main", None) + import main + assert "http://localhost:5173" in main.ALLOWED_ORIGINS + + def test_custom_allowed_origins_parsed(self): + origins = "http://localhost:5173,https://app.example.com" + with patch.dict(os.environ, {"ALLOWED_ORIGINS": origins}, clear=False): + sys.modules.pop("main", None) + import main + assert "http://localhost:5173" in main.ALLOWED_ORIGINS + assert "https://app.example.com" in main.ALLOWED_ORIGINS + + def test_allowed_origins_strips_whitespace(self): + origins = " http://localhost:5173 , https://app.example.com " + with patch.dict(os.environ, {"ALLOWED_ORIGINS": origins}, clear=False): + sys.modules.pop("main", None) + import main + for origin in main.ALLOWED_ORIGINS: + assert origin == origin.strip() + + def test_wildcard_not_used_in_cors(self): + """The orchestrator must NOT use allow_origins=* — it should use ALLOWED_ORIGINS.""" + import main + # Verify no wildcard in the configured origins + assert "*" not in main.ALLOWED_ORIGINS + + +# --------------------------------------------------------------------------- +# Agent model defaults +# --------------------------------------------------------------------------- + +class TestAgentModel: + + def test_agent_auto_restart_defaults_true(self): + import main + main.agents.clear() + main.events.clear() + main.seed_agents() + for agent in main.agents.values(): + assert agent.auto_restart is True + + def test_agent_cpu_starts_at_zero(self): + import main + main.agents.clear() + main.events.clear() + main.seed_agents() + for agent in main.agents.values(): + assert agent.cpu_percent == 0.0 + + def test_agent_memory_starts_at_zero(self): + import main + main.agents.clear() + main.events.clear() + main.seed_agents() + for agent in main.agents.values(): + assert agent.memory_mb == 0.0 + + def test_agent_error_message_starts_none(self): + import main + main.agents.clear() + main.events.clear() + main.seed_agents() + for agent in main.agents.values(): + assert agent.error_message is None + + +# --------------------------------------------------------------------------- +# Gateway token config +# --------------------------------------------------------------------------- + +class TestGatewayTokenConfig: + + def test_gateway_token_none_when_not_set(self): + env = os.environ.copy() + env.pop("GATEWAY_TOKEN", None) + with patch.dict(os.environ, env, clear=True): + sys.modules.pop("main", None) + import main + assert main.GATEWAY_TOKEN is None + + def test_gateway_token_set_from_env(self): + with patch.dict(os.environ, {"GATEWAY_TOKEN": "mysecrettoken"}, clear=False): + sys.modules.pop("main", None) + import main + assert main.GATEWAY_TOKEN == "mysecrettoken" \ No newline at end of file