hermes-agent/tests/tools/test_browser_camofox.py
Teknium 18f3fc8a6f
fix(tests): resolve 17 persistent CI test failures (#15084)
Make the main-branch test suite pass again. Most failures were tests
still asserting old shapes after recent refactors; two were real source
bugs.

Source fixes:
- tools/mcp_tool.py: _kill_orphaned_mcp_children() slept 2s on every
  shutdown even when no tracked PIDs existed, making test_shutdown_is_parallel
  measure ~3s for 3 parallel 1s shutdowns. Early-return when pids is empty.
- hermes_cli/tips.py: tip 105 was 157 chars; corpus max is 150.

Test fixes (mostly stale mock targets / missing fixture fields):
- test_zombie_process_cleanup, test_agent_cache: patch run_agent.cleanup_vm
  (the local name bound at import), not tools.terminal_tool.cleanup_vm.
- test_browser_camofox: patch tools.browser_camofox.load_config, not
  hermes_cli.config.load_config (the source module, not the resolved one).
- test_flush_memories_codex._chat_response_with_memory_call: add
  finish_reason, tool_call.id, tool_call.type so the chat_completions
  transport normalizer doesn't AttributeError.
- test_concurrent_interrupt: polling_tool signature now accepts
  messages= kwarg that _invoke_tool() passes through.
- test_minimax_provider: add _fallback_chain=[] to the __new__'d agent
  so switch_model() doesn't AttributeError.
- test_skills_config: SKILLS_DIR MagicMock + .rglob stopped working
  after the scanner switched to agent.skill_utils.iter_skill_index_files
  (os.walk-based). Point SKILLS_DIR at a real tmp_path and patch
  agent.skill_utils.get_external_skills_dirs.
- test_browser_cdp_tool: browser_cdp toolset was intentionally split into
  'browser-cdp' (commit 96b0f3700) so its stricter check_fn doesn't gate
  the whole browser toolset; test now expects 'browser-cdp'.
- test_registry: add tools.browser_dialog_tool to the expected
  builtin-discovery set (PR #14540 added it).
- test_file_tools TestPatchHints: patch_tool surfaces hints as a '_hint'
  key on the JSON payload, not inline '[Hint: ...' text.
- test_write_deny test_hermes_env: resolve .env via get_hermes_home() so
  the path matches the profile-aware denylist under hermetic HERMES_HOME.
- test_checkpoint_manager test_falls_back_to_parent: guard the walk-up
  so a stray /tmp/pyproject.toml on the host doesn't pick up /tmp as the
  project root.
- test_quick_commands: set cli.session_id in the __new__'d CLI so the
  alias-args path doesn't trip AttributeError when fuzzy-matching leaks
  a skill command across xdist test distribution.
2026-04-24 03:46:46 -07:00

353 lines
15 KiB
Python

"""Tests for the Camofox browser backend."""
import json
import os
from unittest.mock import MagicMock, patch
import pytest
from tools.browser_camofox import (
camofox_back,
camofox_click,
camofox_close,
camofox_console,
camofox_get_images,
camofox_navigate,
camofox_press,
camofox_scroll,
camofox_snapshot,
camofox_type,
camofox_vision,
check_camofox_available,
is_camofox_mode,
)
# ---------------------------------------------------------------------------
# Configuration detection
# ---------------------------------------------------------------------------
class TestCamofoxMode:
def test_disabled_by_default(self, monkeypatch):
monkeypatch.delenv("CAMOFOX_URL", raising=False)
assert is_camofox_mode() is False
def test_enabled_when_url_set(self, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
assert is_camofox_mode() is True
def test_cdp_override_takes_priority(self, monkeypatch):
"""When BROWSER_CDP_URL is set (via /browser connect), CDP takes priority over Camofox."""
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")
assert is_camofox_mode() is False
def test_cdp_override_blank_does_not_disable_camofox(self, monkeypatch):
"""Empty/whitespace BROWSER_CDP_URL should not suppress Camofox."""
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
monkeypatch.setenv("BROWSER_CDP_URL", " ")
assert is_camofox_mode() is True
def test_health_check_unreachable(self, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999")
assert check_camofox_available() is False
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mock_response(status=200, json_data=None):
resp = MagicMock()
resp.status_code = status
resp.json.return_value = json_data or {}
resp.content = b"\x89PNG\r\n\x1a\nfake"
resp.raise_for_status = MagicMock()
return resp
# ---------------------------------------------------------------------------
# Navigate
# ---------------------------------------------------------------------------
class TestCamofoxNavigate:
@patch("tools.browser_camofox.requests.post")
def test_creates_tab_on_first_navigate(self, mock_post, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
mock_post.return_value = _mock_response(json_data={"tabId": "tab1", "url": "https://example.com"})
result = json.loads(camofox_navigate("https://example.com", task_id="t1"))
assert result["success"] is True
assert result["url"] == "https://example.com"
@patch("tools.browser_camofox.requests.post")
def test_navigates_existing_tab(self, mock_post, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
# First call creates tab
mock_post.return_value = _mock_response(json_data={"tabId": "tab2", "url": "https://a.com"})
camofox_navigate("https://a.com", task_id="t2")
# Second call navigates
mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://b.com"})
result = json.loads(camofox_navigate("https://b.com", task_id="t2"))
assert result["success"] is True
assert result["url"] == "https://b.com"
def test_connection_error_returns_helpful_message(self, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999")
result = json.loads(camofox_navigate("https://example.com", task_id="t_err"))
assert result["success"] is False
assert "Cannot connect" in result["error"]
# ---------------------------------------------------------------------------
# Snapshot
# ---------------------------------------------------------------------------
class TestCamofoxSnapshot:
def test_no_session_returns_error(self, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
result = json.loads(camofox_snapshot(task_id="no_such_task"))
assert result["success"] is False
assert "browser_navigate" in result["error"]
@patch("tools.browser_camofox.requests.post")
@patch("tools.browser_camofox.requests.get")
def test_returns_snapshot(self, mock_get, mock_post, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
# Create session
mock_post.return_value = _mock_response(json_data={"tabId": "tab3", "url": "https://x.com"})
camofox_navigate("https://x.com", task_id="t3")
# Return snapshot
mock_get.return_value = _mock_response(json_data={
"snapshot": "- heading \"Test\" [e1]\n- button \"Submit\" [e2]",
"refsCount": 2,
})
result = json.loads(camofox_snapshot(task_id="t3"))
assert result["success"] is True
assert "[e1]" in result["snapshot"]
assert result["element_count"] == 2
# ---------------------------------------------------------------------------
# Click / Type / Scroll / Back / Press
# ---------------------------------------------------------------------------
class TestCamofoxInteractions:
@patch("tools.browser_camofox.requests.post")
def test_click(self, mock_post, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
mock_post.return_value = _mock_response(json_data={"tabId": "tab4", "url": "https://x.com"})
camofox_navigate("https://x.com", task_id="t4")
mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://x.com"})
result = json.loads(camofox_click("@e5", task_id="t4"))
assert result["success"] is True
assert result["clicked"] == "e5"
@patch("tools.browser_camofox.requests.post")
def test_type(self, mock_post, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
mock_post.return_value = _mock_response(json_data={"tabId": "tab5", "url": "https://x.com"})
camofox_navigate("https://x.com", task_id="t5")
mock_post.return_value = _mock_response(json_data={"ok": True})
result = json.loads(camofox_type("@e3", "hello world", task_id="t5"))
assert result["success"] is True
assert result["typed"] == "hello world"
@patch("tools.browser_camofox.requests.post")
def test_scroll(self, mock_post, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
mock_post.return_value = _mock_response(json_data={"tabId": "tab6", "url": "https://x.com"})
camofox_navigate("https://x.com", task_id="t6")
mock_post.return_value = _mock_response(json_data={"ok": True})
result = json.loads(camofox_scroll("down", task_id="t6"))
assert result["success"] is True
assert result["scrolled"] == "down"
@patch("tools.browser_camofox.requests.post")
def test_back(self, mock_post, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
mock_post.return_value = _mock_response(json_data={"tabId": "tab7", "url": "https://x.com"})
camofox_navigate("https://x.com", task_id="t7")
mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://prev.com"})
result = json.loads(camofox_back(task_id="t7"))
assert result["success"] is True
@patch("tools.browser_camofox.requests.post")
def test_press(self, mock_post, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
mock_post.return_value = _mock_response(json_data={"tabId": "tab8", "url": "https://x.com"})
camofox_navigate("https://x.com", task_id="t8")
mock_post.return_value = _mock_response(json_data={"ok": True})
result = json.loads(camofox_press("Enter", task_id="t8"))
assert result["success"] is True
assert result["pressed"] == "Enter"
# ---------------------------------------------------------------------------
# Close
# ---------------------------------------------------------------------------
class TestCamofoxClose:
@patch("tools.browser_camofox.requests.delete")
@patch("tools.browser_camofox.requests.post")
def test_close_session(self, mock_post, mock_delete, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
mock_post.return_value = _mock_response(json_data={"tabId": "tab9", "url": "https://x.com"})
camofox_navigate("https://x.com", task_id="t9")
mock_delete.return_value = _mock_response(json_data={"ok": True})
result = json.loads(camofox_close(task_id="t9"))
assert result["success"] is True
assert result["closed"] is True
def test_close_nonexistent_session(self, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
result = json.loads(camofox_close(task_id="nonexistent"))
assert result["success"] is True
# ---------------------------------------------------------------------------
# Console (limited support)
# ---------------------------------------------------------------------------
class TestCamofoxConsole:
def test_console_returns_empty_with_note(self, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
result = json.loads(camofox_console(task_id="t_console"))
assert result["success"] is True
assert result["total_messages"] == 0
assert "not available" in result["note"]
# ---------------------------------------------------------------------------
# Images
# ---------------------------------------------------------------------------
class TestCamofoxGetImages:
@patch("tools.browser_camofox.requests.post")
@patch("tools.browser_camofox.requests.get")
def test_get_images(self, mock_get, mock_post, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
mock_post.return_value = _mock_response(json_data={"tabId": "tab10", "url": "https://x.com"})
camofox_navigate("https://x.com", task_id="t10")
# camofox_get_images parses images from the accessibility tree snapshot
snapshot_text = (
'- img "Logo"\n'
' /url: https://x.com/img.png\n'
)
mock_get.return_value = _mock_response(json_data={
"snapshot": snapshot_text,
})
result = json.loads(camofox_get_images(task_id="t10"))
assert result["success"] is True
assert result["count"] == 1
assert result["images"][0]["src"] == "https://x.com/img.png"
class TestCamofoxVisionConfig:
@patch("tools.browser_camofox.requests.post")
@patch("tools.browser_camofox._get")
@patch("tools.browser_camofox._get_raw")
def test_camofox_vision_uses_configured_temperature_and_timeout(self, mock_get_raw, mock_get, mock_post, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
mock_post.return_value = _mock_response(json_data={"tabId": "tab11", "url": "https://x.com"})
camofox_navigate("https://x.com", task_id="t11")
snapshot_text = '- button "Submit"\n'
raw_resp = MagicMock()
raw_resp.content = b"fakepng"
mock_get_raw.return_value = raw_resp
mock_get.return_value = {"snapshot": snapshot_text}
mock_response = MagicMock()
mock_choice = MagicMock()
mock_choice.message.content = "Camofox screenshot analysis"
mock_response.choices = [mock_choice]
with (
patch("tools.browser_camofox.open", create=True) as mock_open,
patch("agent.auxiliary_client.call_llm", return_value=mock_response) as mock_llm,
patch("tools.browser_camofox.load_config", return_value={"auxiliary": {"vision": {"temperature": 1, "timeout": 45}}}),
):
mock_open.return_value.__enter__.return_value.read.return_value = b"fakepng"
result = json.loads(camofox_vision("what is on the page?", annotate=True, task_id="t11"))
assert result["success"] is True
assert result["analysis"] == "Camofox screenshot analysis"
assert mock_llm.call_args.kwargs["temperature"] == 1.0
assert mock_llm.call_args.kwargs["timeout"] == 45.0
@patch("tools.browser_camofox.requests.post")
@patch("tools.browser_camofox._get")
@patch("tools.browser_camofox._get_raw")
def test_camofox_vision_defaults_temperature_when_config_omits_it(self, mock_get_raw, mock_get, mock_post, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
mock_post.return_value = _mock_response(json_data={"tabId": "tab12", "url": "https://x.com"})
camofox_navigate("https://x.com", task_id="t12")
snapshot_text = '- button "Submit"\n'
raw_resp = MagicMock()
raw_resp.content = b"fakepng"
mock_get_raw.return_value = raw_resp
mock_get.return_value = {"snapshot": snapshot_text}
mock_response = MagicMock()
mock_choice = MagicMock()
mock_choice.message.content = "Default camofox screenshot analysis"
mock_response.choices = [mock_choice]
with (
patch("tools.browser_camofox.open", create=True) as mock_open,
patch("agent.auxiliary_client.call_llm", return_value=mock_response) as mock_llm,
patch("tools.browser_camofox.load_config", return_value={"auxiliary": {"vision": {}}}),
):
mock_open.return_value.__enter__.return_value.read.return_value = b"fakepng"
result = json.loads(camofox_vision("what is on the page?", annotate=True, task_id="t12"))
assert result["success"] is True
assert result["analysis"] == "Default camofox screenshot analysis"
assert mock_llm.call_args.kwargs["temperature"] == 0.1
assert mock_llm.call_args.kwargs["timeout"] == 120.0
# ---------------------------------------------------------------------------
# Routing integration — verify browser_tool routes to camofox
# ---------------------------------------------------------------------------
class TestBrowserToolRouting:
"""Verify that browser_tool.py delegates to camofox when CAMOFOX_URL is set."""
@patch("tools.browser_camofox.requests.post")
def test_browser_navigate_routes_to_camofox(self, mock_post, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
mock_post.return_value = _mock_response(json_data={"tabId": "tab_rt", "url": "https://example.com"})
from tools.browser_tool import browser_navigate
# Bypass SSRF check for test URL
with patch("tools.browser_tool._is_safe_url", return_value=True):
result = json.loads(browser_navigate("https://example.com", task_id="t_route"))
assert result["success"] is True
def test_check_requirements_passes_with_camofox(self, monkeypatch):
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
from tools.browser_tool import check_browser_requirements
assert check_browser_requirements() is True