test_openclaw_runtime_startup.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. from __future__ import annotations
  2. import subprocess
  3. from contextlib import asynccontextmanager
  4. from dataclasses import replace
  5. from pathlib import Path
  6. from typing import Any
  7. import pytest
  8. pytest.importorskip("fastapi.testclient")
  9. from fastapi import FastAPI, Request
  10. from fastapi.testclient import TestClient
  11. from agency_swarm.integrations import openclaw as openclaw_mod
  12. from agency_swarm.integrations.openclaw import (
  13. OpenClawIntegrationConfig,
  14. OpenClawRuntime,
  15. attach_openclaw_to_fastapi,
  16. normalize_openclaw_responses_request,
  17. )
  18. from tests.integration.fastapi._openclaw_test_support import _build_openclaw_config
  19. def test_openclaw_runtime_uses_lifespan_hooks(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
  20. app = FastAPI()
  21. runtime = attach_openclaw_to_fastapi(app, replace(_build_openclaw_config(tmp_path), autostart=True))
  22. calls = {"start": 0, "stop": 0}
  23. to_thread_calls: list[str] = []
  24. def _start() -> None:
  25. calls["start"] += 1
  26. def _stop() -> None:
  27. calls["stop"] += 1
  28. async def _to_thread(func: Any, *args: Any, **kwargs: Any) -> Any:
  29. to_thread_calls.append(func.__name__)
  30. return func(*args, **kwargs)
  31. monkeypatch.setattr(runtime, "start", _start)
  32. monkeypatch.setattr(runtime, "stop", _stop)
  33. monkeypatch.setattr(openclaw_mod.asyncio, "to_thread", _to_thread)
  34. with TestClient(app):
  35. assert calls == {"start": 1, "stop": 0}
  36. assert calls == {"start": 1, "stop": 1}
  37. assert to_thread_calls == ["_start", "_stop"]
  38. def test_openclaw_lifespan_preserves_existing_state(tmp_path: Path) -> None:
  39. @asynccontextmanager
  40. async def _existing_lifespan(_app: FastAPI):
  41. yield {"existing_marker": "kept"}
  42. app = FastAPI(lifespan=_existing_lifespan)
  43. attach_openclaw_to_fastapi(app, replace(_build_openclaw_config(tmp_path), autostart=False))
  44. @app.get("/state-marker")
  45. async def state_marker(request: Request) -> dict[str, str]:
  46. return {"existing_marker": request.state.existing_marker}
  47. with TestClient(app) as client:
  48. response = client.get("/state-marker")
  49. assert response.status_code == 200
  50. assert response.json() == {"existing_marker": "kept"}
  51. def test_openclaw_runtime_does_not_stop_when_autostart_disabled(
  52. tmp_path: Path, monkeypatch: pytest.MonkeyPatch
  53. ) -> None:
  54. app = FastAPI()
  55. runtime = attach_openclaw_to_fastapi(app, replace(_build_openclaw_config(tmp_path), autostart=False))
  56. calls = {"stop": 0}
  57. to_thread_calls: list[str] = []
  58. def _stop() -> None:
  59. calls["stop"] += 1
  60. async def _to_thread(func: Any, *args: Any, **kwargs: Any) -> Any:
  61. to_thread_calls.append(func.__name__)
  62. return func(*args, **kwargs)
  63. monkeypatch.setattr(runtime, "stop", _stop)
  64. monkeypatch.setattr(openclaw_mod.asyncio, "to_thread", _to_thread)
  65. with TestClient(app):
  66. pass
  67. assert calls == {"stop": 0}
  68. assert to_thread_calls == []
  69. def test_openclaw_port_probe_supports_ipv6(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
  70. config = replace(_build_openclaw_config(tmp_path), host="::1")
  71. class _FakeSocket:
  72. def __init__(self, family: int, _socktype: int, _proto: int) -> None:
  73. self.family = family
  74. def __enter__(self) -> _FakeSocket:
  75. return self
  76. def __exit__(self, exc_type, exc, tb) -> None:
  77. return None
  78. def settimeout(self, _timeout: float) -> None:
  79. return None
  80. def connect(self, _sockaddr: Any) -> None:
  81. if self.family == openclaw_mod.socket.AF_INET6:
  82. return None
  83. raise OSError("unreachable")
  84. monkeypatch.setattr(
  85. openclaw_mod.socket,
  86. "getaddrinfo",
  87. lambda _host, _port, type: [
  88. (openclaw_mod.socket.AF_INET, openclaw_mod.socket.SOCK_STREAM, 0, "", ("127.0.0.1", config.port)),
  89. (openclaw_mod.socket.AF_INET6, openclaw_mod.socket.SOCK_STREAM, 0, "", ("::1", config.port, 0, 0)),
  90. ],
  91. )
  92. monkeypatch.setattr(openclaw_mod.socket, "socket", _FakeSocket)
  93. assert openclaw_mod._is_upstream_port_open(config) is True
  94. def test_openclaw_upstream_base_url_brackets_ipv6_host(tmp_path: Path) -> None:
  95. config = replace(_build_openclaw_config(tmp_path), host="::1")
  96. assert config.upstream_base_url == "http://[::1]:18789"
  97. def test_openclaw_gateway_command_port_detection_supports_equals_syntax(tmp_path: Path) -> None:
  98. config = _build_openclaw_config(tmp_path)
  99. runtime = OpenClawRuntime(replace(config, port=19000, gateway_command="openclaw gateway --port=19000"))
  100. command = runtime._resolve_gateway_command()
  101. port_args = [arg for arg in command if arg == "--port" or arg.startswith("--port=")]
  102. assert port_args == ["--port=19000"]
  103. def test_openclaw_gateway_command_rejects_invalid_port_value(tmp_path: Path) -> None:
  104. config = _build_openclaw_config(tmp_path)
  105. runtime = OpenClawRuntime(replace(config, gateway_command="openclaw gateway --port=abc"))
  106. with pytest.raises(RuntimeError, match="Invalid OPENCLAW_GATEWAY_COMMAND --port value"):
  107. runtime._resolve_gateway_command()
  108. def test_openclaw_gateway_command_rejects_port_mismatch(tmp_path: Path) -> None:
  109. config = _build_openclaw_config(tmp_path)
  110. runtime = OpenClawRuntime(replace(config, port=18789, gateway_command="openclaw gateway --port=19000"))
  111. with pytest.raises(RuntimeError, match="does not match configured OPENCLAW_PORT"):
  112. runtime._resolve_gateway_command()
  113. def test_openclaw_config_from_env_prefers_gateway_command_port(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
  114. monkeypatch.setenv("OPENCLAW_HOME", str(tmp_path / "home"))
  115. monkeypatch.setenv("OPENCLAW_PORT", "18789")
  116. monkeypatch.setenv("OPENCLAW_GATEWAY_COMMAND", "openclaw gateway --port=19000")
  117. config = OpenClawIntegrationConfig.from_env()
  118. assert config.port == 19000
  119. assert config.upstream_base_url == "http://127.0.0.1:19000"
  120. def test_openclaw_start_fails_when_port_is_already_in_use(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
  121. config = _build_openclaw_config(tmp_path)
  122. runtime = OpenClawRuntime(config)
  123. monkeypatch.setattr(runtime, "_is_port_open", lambda: True)
  124. with pytest.raises(RuntimeError, match="already in use"):
  125. runtime.start()
  126. def test_openclaw_metadata_validation_rejects_non_json_serializable_values() -> None:
  127. with pytest.raises(ValueError, match="metadata\\['bad'\\] must be JSON-serializable"):
  128. normalize_openclaw_responses_request(
  129. {
  130. "model": "openclaw:main",
  131. "input": "hello",
  132. "metadata": {"bad": {1, 2}},
  133. }
  134. )
  135. def test_openclaw_from_env_handles_boolean_and_unparseable_gateway_command(
  136. monkeypatch: pytest.MonkeyPatch,
  137. tmp_path: Path,
  138. ) -> None:
  139. monkeypatch.setenv("OPENCLAW_HOME", str(tmp_path / "home"))
  140. monkeypatch.setenv("OPENCLAW_AUTOSTART", "false")
  141. monkeypatch.setenv("OPENCLAW_GATEWAY_COMMAND", 'openclaw gateway --port "broken')
  142. monkeypatch.setenv("OPENCLAW_PORT", "19001")
  143. config = OpenClawIntegrationConfig.from_env()
  144. assert config.autostart is False
  145. assert config.port == 19001
  146. assert config.gateway_command == 'openclaw gateway --port "broken'
  147. def test_openclaw_from_env_defaults_gateway_token_to_app_token(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
  148. monkeypatch.setenv("OPENCLAW_HOME", str(tmp_path / "home"))
  149. monkeypatch.setenv("APP_TOKEN", "app-token")
  150. monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False)
  151. config = OpenClawIntegrationConfig.from_env()
  152. assert config.gateway_token == "app-token"
  153. def test_openclaw_from_env_defaults_gateway_token_to_local_value_when_unset(
  154. monkeypatch: pytest.MonkeyPatch, tmp_path: Path
  155. ) -> None:
  156. monkeypatch.setenv("OPENCLAW_HOME", str(tmp_path / "home"))
  157. monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False)
  158. monkeypatch.delenv("APP_TOKEN", raising=False)
  159. config = OpenClawIntegrationConfig.from_env()
  160. assert config.gateway_token == "openclaw-local-token"
  161. def test_openclaw_extract_port_parser_handles_edge_values() -> None:
  162. assert openclaw_mod._extract_port_from_gateway_command(["openclaw", "gateway", "--port", "19000"]) == 19000
  163. assert openclaw_mod._extract_port_from_gateway_command(["openclaw", "gateway", "--port=19000"]) == 19000
  164. assert openclaw_mod._extract_port_from_gateway_command(["openclaw", "gateway", "--port"]) is None
  165. assert openclaw_mod._extract_port_from_gateway_command(["openclaw", "gateway", "--port=abc"]) is None
  166. assert openclaw_mod._extract_port_from_gateway_command(["openclaw", "gateway", "--port=70000"]) is None
  167. def test_openclaw_select_compatible_node_binary_prefers_explicit_env(monkeypatch: pytest.MonkeyPatch) -> None:
  168. monkeypatch.setenv("OPENCLAW_NODE_BIN", "/custom/node")
  169. def _fake_run(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
  170. binary = command[0]
  171. versions = {
  172. "/custom/node": "v22.12.1\n",
  173. "/tmp/path-node": "v20.18.3\n",
  174. }
  175. if binary in versions:
  176. return subprocess.CompletedProcess(command, 0, versions[binary], "")
  177. raise FileNotFoundError(binary)
  178. monkeypatch.setattr(openclaw_mod.shutil, "which", lambda name: "/tmp/path-node" if name == "node" else None)
  179. monkeypatch.setattr(openclaw_mod.subprocess, "run", _fake_run)
  180. binary, version = openclaw_mod._select_compatible_node_binary()
  181. assert binary == "/custom/node"
  182. assert version == (22, 12, 1)
  183. def test_openclaw_select_compatible_node_binary_uses_fallback_candidates(monkeypatch: pytest.MonkeyPatch) -> None:
  184. monkeypatch.delenv("OPENCLAW_NODE_BIN", raising=False)
  185. def _fake_run(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
  186. binary = command[0]
  187. versions = {
  188. "/tmp/path-node": "v20.18.3\n",
  189. "/opt/homebrew/bin/node": "v22.22.0\n",
  190. "/usr/local/bin/node": "v18.20.0\n",
  191. }
  192. if binary in versions:
  193. return subprocess.CompletedProcess(command, 0, versions[binary], "")
  194. raise FileNotFoundError(binary)
  195. monkeypatch.setattr(openclaw_mod.shutil, "which", lambda name: "/tmp/path-node" if name == "node" else None)
  196. monkeypatch.setattr(openclaw_mod.subprocess, "run", _fake_run)
  197. binary, version = openclaw_mod._select_compatible_node_binary()
  198. assert binary == "/opt/homebrew/bin/node"
  199. assert version == (22, 22, 0)
  200. def test_openclaw_select_compatible_node_binary_reports_highest_detected_when_incompatible(
  201. monkeypatch: pytest.MonkeyPatch,
  202. ) -> None:
  203. monkeypatch.delenv("OPENCLAW_NODE_BIN", raising=False)
  204. def _fake_run(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
  205. binary = command[0]
  206. versions = {
  207. "/tmp/path-node": "v20.18.3\n",
  208. "/opt/homebrew/bin/node": "v21.9.0\n",
  209. "/usr/local/bin/node": "v19.4.0\n",
  210. }
  211. if binary in versions:
  212. return subprocess.CompletedProcess(command, 0, versions[binary], "")
  213. raise FileNotFoundError(binary)
  214. monkeypatch.setattr(openclaw_mod.shutil, "which", lambda name: "/tmp/path-node" if name == "node" else None)
  215. monkeypatch.setattr(openclaw_mod.subprocess, "run", _fake_run)
  216. binary, version = openclaw_mod._select_compatible_node_binary()
  217. assert binary is None
  218. assert version == (21, 9, 0)
  219. def test_openclaw_merge_provider_keys_from_dotenv_loads_only_missing_keys(
  220. monkeypatch: pytest.MonkeyPatch,
  221. tmp_path: Path,
  222. ) -> None:
  223. dotenv_path = tmp_path / "openclaw.env"
  224. dotenv_path.write_text(
  225. "OPENAI_API_KEY=from_dotenv\nANTHROPIC_API_KEY=anthropic_dotenv\nIGNORED=value\n",
  226. encoding="utf-8",
  227. )
  228. monkeypatch.setenv("OPENCLAW_DOTENV_PATH", str(dotenv_path))
  229. runtime = OpenClawRuntime(_build_openclaw_config(tmp_path))
  230. env = {"OPENAI_API_KEY": "already_set"}
  231. runtime._merge_provider_keys_from_dotenv(env)
  232. assert env["OPENAI_API_KEY"] == "already_set"
  233. assert env["ANTHROPIC_API_KEY"] == "anthropic_dotenv"
  234. assert "IGNORED" not in env
  235. def test_openclaw_resolve_gateway_command_errors_when_binary_is_unavailable(
  236. monkeypatch: pytest.MonkeyPatch,
  237. tmp_path: Path,
  238. ) -> None:
  239. config = replace(_build_openclaw_config(tmp_path), gateway_command=None)
  240. runtime = OpenClawRuntime(config)
  241. monkeypatch.setattr("agency_swarm.integrations.openclaw.shutil.which", lambda _name: None)
  242. with pytest.raises(RuntimeError, match="OpenClaw runtime unavailable"):
  243. runtime._resolve_gateway_command()
  244. def test_openclaw_resolve_gateway_command_reports_invalid_shell_quoting(tmp_path: Path) -> None:
  245. runtime = OpenClawRuntime(
  246. replace(_build_openclaw_config(tmp_path), gateway_command='openclaw gateway --port "broken')
  247. )
  248. with pytest.raises(RuntimeError, match="Invalid OPENCLAW_GATEWAY_COMMAND"):
  249. runtime._resolve_gateway_command()