| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347 |
- from __future__ import annotations
- import subprocess
- from contextlib import asynccontextmanager
- from dataclasses import replace
- from pathlib import Path
- from typing import Any
- import pytest
- pytest.importorskip("fastapi.testclient")
- from fastapi import FastAPI, Request
- from fastapi.testclient import TestClient
- from agency_swarm.integrations import openclaw as openclaw_mod
- from agency_swarm.integrations.openclaw import (
- OpenClawIntegrationConfig,
- OpenClawRuntime,
- attach_openclaw_to_fastapi,
- normalize_openclaw_responses_request,
- )
- from tests.integration.fastapi._openclaw_test_support import _build_openclaw_config
- def test_openclaw_runtime_uses_lifespan_hooks(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
- app = FastAPI()
- runtime = attach_openclaw_to_fastapi(app, replace(_build_openclaw_config(tmp_path), autostart=True))
- calls = {"start": 0, "stop": 0}
- to_thread_calls: list[str] = []
- def _start() -> None:
- calls["start"] += 1
- def _stop() -> None:
- calls["stop"] += 1
- async def _to_thread(func: Any, *args: Any, **kwargs: Any) -> Any:
- to_thread_calls.append(func.__name__)
- return func(*args, **kwargs)
- monkeypatch.setattr(runtime, "start", _start)
- monkeypatch.setattr(runtime, "stop", _stop)
- monkeypatch.setattr(openclaw_mod.asyncio, "to_thread", _to_thread)
- with TestClient(app):
- assert calls == {"start": 1, "stop": 0}
- assert calls == {"start": 1, "stop": 1}
- assert to_thread_calls == ["_start", "_stop"]
- def test_openclaw_lifespan_preserves_existing_state(tmp_path: Path) -> None:
- @asynccontextmanager
- async def _existing_lifespan(_app: FastAPI):
- yield {"existing_marker": "kept"}
- app = FastAPI(lifespan=_existing_lifespan)
- attach_openclaw_to_fastapi(app, replace(_build_openclaw_config(tmp_path), autostart=False))
- @app.get("/state-marker")
- async def state_marker(request: Request) -> dict[str, str]:
- return {"existing_marker": request.state.existing_marker}
- with TestClient(app) as client:
- response = client.get("/state-marker")
- assert response.status_code == 200
- assert response.json() == {"existing_marker": "kept"}
- def test_openclaw_runtime_does_not_stop_when_autostart_disabled(
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch
- ) -> None:
- app = FastAPI()
- runtime = attach_openclaw_to_fastapi(app, replace(_build_openclaw_config(tmp_path), autostart=False))
- calls = {"stop": 0}
- to_thread_calls: list[str] = []
- def _stop() -> None:
- calls["stop"] += 1
- async def _to_thread(func: Any, *args: Any, **kwargs: Any) -> Any:
- to_thread_calls.append(func.__name__)
- return func(*args, **kwargs)
- monkeypatch.setattr(runtime, "stop", _stop)
- monkeypatch.setattr(openclaw_mod.asyncio, "to_thread", _to_thread)
- with TestClient(app):
- pass
- assert calls == {"stop": 0}
- assert to_thread_calls == []
- def test_openclaw_port_probe_supports_ipv6(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
- config = replace(_build_openclaw_config(tmp_path), host="::1")
- class _FakeSocket:
- def __init__(self, family: int, _socktype: int, _proto: int) -> None:
- self.family = family
- def __enter__(self) -> _FakeSocket:
- return self
- def __exit__(self, exc_type, exc, tb) -> None:
- return None
- def settimeout(self, _timeout: float) -> None:
- return None
- def connect(self, _sockaddr: Any) -> None:
- if self.family == openclaw_mod.socket.AF_INET6:
- return None
- raise OSError("unreachable")
- monkeypatch.setattr(
- openclaw_mod.socket,
- "getaddrinfo",
- lambda _host, _port, type: [
- (openclaw_mod.socket.AF_INET, openclaw_mod.socket.SOCK_STREAM, 0, "", ("127.0.0.1", config.port)),
- (openclaw_mod.socket.AF_INET6, openclaw_mod.socket.SOCK_STREAM, 0, "", ("::1", config.port, 0, 0)),
- ],
- )
- monkeypatch.setattr(openclaw_mod.socket, "socket", _FakeSocket)
- assert openclaw_mod._is_upstream_port_open(config) is True
- def test_openclaw_upstream_base_url_brackets_ipv6_host(tmp_path: Path) -> None:
- config = replace(_build_openclaw_config(tmp_path), host="::1")
- assert config.upstream_base_url == "http://[::1]:18789"
- def test_openclaw_gateway_command_port_detection_supports_equals_syntax(tmp_path: Path) -> None:
- config = _build_openclaw_config(tmp_path)
- runtime = OpenClawRuntime(replace(config, port=19000, gateway_command="openclaw gateway --port=19000"))
- command = runtime._resolve_gateway_command()
- port_args = [arg for arg in command if arg == "--port" or arg.startswith("--port=")]
- assert port_args == ["--port=19000"]
- def test_openclaw_gateway_command_rejects_invalid_port_value(tmp_path: Path) -> None:
- config = _build_openclaw_config(tmp_path)
- runtime = OpenClawRuntime(replace(config, gateway_command="openclaw gateway --port=abc"))
- with pytest.raises(RuntimeError, match="Invalid OPENCLAW_GATEWAY_COMMAND --port value"):
- runtime._resolve_gateway_command()
- def test_openclaw_gateway_command_rejects_port_mismatch(tmp_path: Path) -> None:
- config = _build_openclaw_config(tmp_path)
- runtime = OpenClawRuntime(replace(config, port=18789, gateway_command="openclaw gateway --port=19000"))
- with pytest.raises(RuntimeError, match="does not match configured OPENCLAW_PORT"):
- runtime._resolve_gateway_command()
- def test_openclaw_config_from_env_prefers_gateway_command_port(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
- monkeypatch.setenv("OPENCLAW_HOME", str(tmp_path / "home"))
- monkeypatch.setenv("OPENCLAW_PORT", "18789")
- monkeypatch.setenv("OPENCLAW_GATEWAY_COMMAND", "openclaw gateway --port=19000")
- config = OpenClawIntegrationConfig.from_env()
- assert config.port == 19000
- assert config.upstream_base_url == "http://127.0.0.1:19000"
- def test_openclaw_start_fails_when_port_is_already_in_use(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
- config = _build_openclaw_config(tmp_path)
- runtime = OpenClawRuntime(config)
- monkeypatch.setattr(runtime, "_is_port_open", lambda: True)
- with pytest.raises(RuntimeError, match="already in use"):
- runtime.start()
- def test_openclaw_metadata_validation_rejects_non_json_serializable_values() -> None:
- with pytest.raises(ValueError, match="metadata\\['bad'\\] must be JSON-serializable"):
- normalize_openclaw_responses_request(
- {
- "model": "openclaw:main",
- "input": "hello",
- "metadata": {"bad": {1, 2}},
- }
- )
- def test_openclaw_from_env_handles_boolean_and_unparseable_gateway_command(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
- ) -> None:
- monkeypatch.setenv("OPENCLAW_HOME", str(tmp_path / "home"))
- monkeypatch.setenv("OPENCLAW_AUTOSTART", "false")
- monkeypatch.setenv("OPENCLAW_GATEWAY_COMMAND", 'openclaw gateway --port "broken')
- monkeypatch.setenv("OPENCLAW_PORT", "19001")
- config = OpenClawIntegrationConfig.from_env()
- assert config.autostart is False
- assert config.port == 19001
- assert config.gateway_command == 'openclaw gateway --port "broken'
- def test_openclaw_from_env_defaults_gateway_token_to_app_token(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
- monkeypatch.setenv("OPENCLAW_HOME", str(tmp_path / "home"))
- monkeypatch.setenv("APP_TOKEN", "app-token")
- monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False)
- config = OpenClawIntegrationConfig.from_env()
- assert config.gateway_token == "app-token"
- def test_openclaw_from_env_defaults_gateway_token_to_local_value_when_unset(
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
- ) -> None:
- monkeypatch.setenv("OPENCLAW_HOME", str(tmp_path / "home"))
- monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False)
- monkeypatch.delenv("APP_TOKEN", raising=False)
- config = OpenClawIntegrationConfig.from_env()
- assert config.gateway_token == "openclaw-local-token"
- def test_openclaw_extract_port_parser_handles_edge_values() -> None:
- assert openclaw_mod._extract_port_from_gateway_command(["openclaw", "gateway", "--port", "19000"]) == 19000
- assert openclaw_mod._extract_port_from_gateway_command(["openclaw", "gateway", "--port=19000"]) == 19000
- assert openclaw_mod._extract_port_from_gateway_command(["openclaw", "gateway", "--port"]) is None
- assert openclaw_mod._extract_port_from_gateway_command(["openclaw", "gateway", "--port=abc"]) is None
- assert openclaw_mod._extract_port_from_gateway_command(["openclaw", "gateway", "--port=70000"]) is None
- def test_openclaw_select_compatible_node_binary_prefers_explicit_env(monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.setenv("OPENCLAW_NODE_BIN", "/custom/node")
- def _fake_run(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
- binary = command[0]
- versions = {
- "/custom/node": "v22.12.1\n",
- "/tmp/path-node": "v20.18.3\n",
- }
- if binary in versions:
- return subprocess.CompletedProcess(command, 0, versions[binary], "")
- raise FileNotFoundError(binary)
- monkeypatch.setattr(openclaw_mod.shutil, "which", lambda name: "/tmp/path-node" if name == "node" else None)
- monkeypatch.setattr(openclaw_mod.subprocess, "run", _fake_run)
- binary, version = openclaw_mod._select_compatible_node_binary()
- assert binary == "/custom/node"
- assert version == (22, 12, 1)
- def test_openclaw_select_compatible_node_binary_uses_fallback_candidates(monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.delenv("OPENCLAW_NODE_BIN", raising=False)
- def _fake_run(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
- binary = command[0]
- versions = {
- "/tmp/path-node": "v20.18.3\n",
- "/opt/homebrew/bin/node": "v22.22.0\n",
- "/usr/local/bin/node": "v18.20.0\n",
- }
- if binary in versions:
- return subprocess.CompletedProcess(command, 0, versions[binary], "")
- raise FileNotFoundError(binary)
- monkeypatch.setattr(openclaw_mod.shutil, "which", lambda name: "/tmp/path-node" if name == "node" else None)
- monkeypatch.setattr(openclaw_mod.subprocess, "run", _fake_run)
- binary, version = openclaw_mod._select_compatible_node_binary()
- assert binary == "/opt/homebrew/bin/node"
- assert version == (22, 22, 0)
- def test_openclaw_select_compatible_node_binary_reports_highest_detected_when_incompatible(
- monkeypatch: pytest.MonkeyPatch,
- ) -> None:
- monkeypatch.delenv("OPENCLAW_NODE_BIN", raising=False)
- def _fake_run(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
- binary = command[0]
- versions = {
- "/tmp/path-node": "v20.18.3\n",
- "/opt/homebrew/bin/node": "v21.9.0\n",
- "/usr/local/bin/node": "v19.4.0\n",
- }
- if binary in versions:
- return subprocess.CompletedProcess(command, 0, versions[binary], "")
- raise FileNotFoundError(binary)
- monkeypatch.setattr(openclaw_mod.shutil, "which", lambda name: "/tmp/path-node" if name == "node" else None)
- monkeypatch.setattr(openclaw_mod.subprocess, "run", _fake_run)
- binary, version = openclaw_mod._select_compatible_node_binary()
- assert binary is None
- assert version == (21, 9, 0)
- def test_openclaw_merge_provider_keys_from_dotenv_loads_only_missing_keys(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
- ) -> None:
- dotenv_path = tmp_path / "openclaw.env"
- dotenv_path.write_text(
- "OPENAI_API_KEY=from_dotenv\nANTHROPIC_API_KEY=anthropic_dotenv\nIGNORED=value\n",
- encoding="utf-8",
- )
- monkeypatch.setenv("OPENCLAW_DOTENV_PATH", str(dotenv_path))
- runtime = OpenClawRuntime(_build_openclaw_config(tmp_path))
- env = {"OPENAI_API_KEY": "already_set"}
- runtime._merge_provider_keys_from_dotenv(env)
- assert env["OPENAI_API_KEY"] == "already_set"
- assert env["ANTHROPIC_API_KEY"] == "anthropic_dotenv"
- assert "IGNORED" not in env
- def test_openclaw_resolve_gateway_command_errors_when_binary_is_unavailable(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
- ) -> None:
- config = replace(_build_openclaw_config(tmp_path), gateway_command=None)
- runtime = OpenClawRuntime(config)
- monkeypatch.setattr("agency_swarm.integrations.openclaw.shutil.which", lambda _name: None)
- with pytest.raises(RuntimeError, match="OpenClaw runtime unavailable"):
- runtime._resolve_gateway_command()
- def test_openclaw_resolve_gateway_command_reports_invalid_shell_quoting(tmp_path: Path) -> None:
- runtime = OpenClawRuntime(
- replace(_build_openclaw_config(tmp_path), gateway_command='openclaw gateway --port "broken')
- )
- with pytest.raises(RuntimeError, match="Invalid OPENCLAW_GATEWAY_COMMAND"):
- runtime._resolve_gateway_command()
|