Files
hermes-agent/tests/tools/test_browser_open_timeout.py
ethernet 6756d11b5f fix(pm): ship full chromium without headless shell
Full Chromium serves both headed and headless sessions. The separate
shell duplicates the browser payload and is not needed for either mode.

Remove the shell from PM and Docker. Select the managed Chromium
executable for agent-browser and the full Chromium channel for direct
Playwright callers. Route setup through PM and remove retired packages
from cached bundle stores without changing the user's tool store.

Update signing, architecture checks, launch probes and install guidance.
Leave llama packages and Docker archive cleanup unchanged.

Verification:
- Real agent-browser navigation, clicks, DOM reads and screenshots pass
  in headed and headless modes with the same Chromium executable.
- The direct Playwright doctor probe passes.
- Focused Python and desktop packaging tests pass, as do six Docker
  checks and both real-browser task-scroll tests.
- The built linux/amd64 image is 1.393 GB compressed, 223.6 MB smaller.
- The broader PM suite and two unrelated setup tests still fail.
  Those failures reproduce on unchanged HEAD.
- Five updated eval scripts parse; their full scenarios were not run.
2026-09-10 16:08:03 -04:00

183 lines
8.0 KiB
Python

"""Tests for browser first-open timeout and timeout diagnostics."""
import subprocess
from unittest.mock import Mock, patch
import pytest
import tools.browser_tool as bt
from tools import browser_tool_session as bt_session
from tools import browser_tool_lifecycle as bt_lifecycle
from tools import browser_tool_cloud as bt_cloud
from tools import browser_tool_install as bt_install
@pytest.fixture(autouse=True)
def _reset_browser_caches(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools"))
bt._cached_command_timeout = None
bt._command_timeout_resolved = False
bt._active_sessions.clear()
bt._session_last_activity.clear()
bt._last_active_session_key.clear()
yield
bt._cached_command_timeout = None
bt._command_timeout_resolved = False
bt._active_sessions.clear()
bt._session_last_activity.clear()
bt._last_active_session_key.clear()
class TestOpenCommandTimeout:
def test_first_open_uses_longer_floor(self, monkeypatch):
monkeypatch.setattr(bt, "_get_command_timeout", lambda: 30)
assert bt._get_open_command_timeout(first_open=True) == bt.MIN_FIRST_OPEN_TIMEOUT
assert bt._get_open_command_timeout(first_open=False) == bt.MIN_OPEN_TIMEOUT
def test_respects_config_above_floor(self, monkeypatch):
monkeypatch.setattr(bt, "_get_command_timeout", lambda: 180)
assert bt._get_open_command_timeout(first_open=True) == 180
assert bt._get_open_command_timeout(first_open=False) == 180
class TestSandboxBypass:
def test_docker_triggers_bypass(self, monkeypatch):
monkeypatch.setattr("tools.browser_tool_install._running_in_docker", lambda: True)
assert bt_session._needs_chromium_sandbox_bypass() is True
def test_apparmor_userns_triggers_bypass(self, monkeypatch, tmp_path):
monkeypatch.setattr("tools.browser_tool_install._running_in_docker", lambda: False)
sysctl = tmp_path / "apparmor_restrict_unprivileged_userns"
sysctl.write_text("1\n", encoding="utf-8")
import builtins
real_open = builtins.open
def _open(path, *args, **kwargs):
if "apparmor_restrict_unprivileged_userns" in str(path):
return real_open(sysctl, *args, **kwargs)
return real_open(path, *args, **kwargs)
monkeypatch.setattr(builtins, "open", _open)
assert bt_session._needs_chromium_sandbox_bypass() is True
class TestTimeoutErrorFormatting:
def test_includes_stderr_detail(self):
err = bt_session._format_browser_timeout_error(
"open",
120,
"",
"Daemon process exited during startup",
)
assert "120 seconds" in err
assert "Daemon process exited" in err
@pytest.mark.parametrize("stderr", ["", "Chromium sandbox launch failed"])
def test_local_install_hint(self, monkeypatch, stderr):
monkeypatch.setattr("tools.browser_tool_cloud._is_local_mode", lambda: True)
monkeypatch.setattr("tools.browser_tool_install._running_in_docker", lambda: False)
err = bt_session._format_browser_timeout_error("open", 60, "", stderr)
assert "playwright install-deps chromium" in err
assert "agent-browser install" not in err
if not stderr:
assert "hermes pm install chromium" in err
class TestReadCommandOutputFiles:
def test_reads_stdout_and_stderr(self, tmp_path):
stdout_path = tmp_path / "out"
stderr_path = tmp_path / "err"
stdout_path.write_text("ok", encoding="utf-8")
stderr_path.write_text("warn", encoding="utf-8")
stdout, stderr = bt_session._read_command_output_files(str(stdout_path), str(stderr_path))
assert stdout == "ok"
assert stderr == "warn"
class TestCommandTimeoutRecovery:
@pytest.mark.parametrize("cloud", [False, True])
def test_timeout_replaces_only_stuck_client(self, monkeypatch, tmp_path, cloud):
task_id = "stuck-command"
session_info = {
"session_name": "stuck-session",
"bb_session_id": "cloud-session-1" if cloud else None,
"cdp_url": "ws://cloud.invalid/devtools/browser/1" if cloud else None,
}
bt._active_sessions[task_id] = session_info
bt._session_last_activity[task_id] = 1.0
bt._last_active_session_key[task_id] = task_id
process = Mock()
process.returncode = 0
process.wait.side_effect = [subprocess.TimeoutExpired("agent-browser", 1), -9, 0]
supervisor_events = []
monkeypatch.setattr(bt_install, "_find_agent_browser", lambda: "agent-browser")
monkeypatch.setattr(bt_install, "_chromium_installed", lambda: True)
monkeypatch.setattr("tools.browser_tool_lifecycle._start_browser_cleanup_thread", lambda: None)
monkeypatch.setattr("tools.browser_tool_cdp._ensure_cdp_supervisor", lambda _: supervisor_events.append("ensure"))
monkeypatch.setattr("tools.browser_tool_cdp._stop_cdp_supervisor", lambda _: supervisor_events.append("stop"))
monkeypatch.setattr(bt, "_socket_safe_tmpdir", lambda: str(tmp_path))
monkeypatch.setattr("tools.browser_tool_lifecycle._write_owner_pid", lambda *_args: None)
monkeypatch.setattr(bt, "_build_browser_env", lambda: {})
monkeypatch.setattr("tools.browser_tool_install._merge_browser_path", lambda value: value)
monkeypatch.setattr(subprocess, "Popen", lambda *_args, **_kwargs: process)
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False)
bt_session._run_browser_command(task_id, "click", ["@e1"], timeout=1)
assert task_id not in bt._last_active_session_key
assert not (tmp_path / "agent-browser-stuck-session").exists()
if not cloud:
assert task_id not in bt._active_sessions and task_id not in bt._session_last_activity
return
replacement = bt._active_sessions[task_id]
assert replacement is not session_info
assert replacement["session_name"] != "stuck-session"
assert replacement["bb_session_id"] == "cloud-session-1"
assert bt_session._get_session_info(task_id) is replacement
provider = Mock()
monkeypatch.setattr(bt_cloud, "_get_cloud_provider", lambda: provider)
bt_lifecycle.cleanup_browser(task_id)
provider.close_session.assert_called_once_with("cloud-session-1")
assert supervisor_events == ["ensure", "stop", "stop"]
def test_stale_timeout_cannot_remove_concurrent_replacement(self, tmp_path):
stale, replacement = {"session_name": "stale"}, {"session_name": "replacement"}
bt._active_sessions["race"] = replacement
bt_session._discard_timed_out_browser_session("race", stale, str(tmp_path))
assert bt._active_sessions["race"] is replacement
assert tmp_path.exists()
class TestBrowserNavigateOpenTimeout:
def test_first_navigation_uses_first_open_timeout(self, monkeypatch):
captured: dict = {}
def fake_run(task_id, command, args, timeout=None):
if command == "open":
captured["timeout"] = timeout
return {"success": True, "data": {"title": "t", "url": args[0] if args else ""}}
monkeypatch.setattr(bt, "_get_open_command_timeout", lambda first_open=False: 120 if first_open else 60)
monkeypatch.setattr(bt_session, "_run_browser_command", fake_run)
monkeypatch.setattr(bt_session, "_get_session_info", lambda key: {"_first_nav": True, "features": {}})
monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False)
monkeypatch.setattr(bt_cloud, "_is_local_backend", lambda: True)
monkeypatch.setattr(bt, "_is_local_sidecar_key", lambda key: False)
monkeypatch.setattr(
bt, "_navigation_session_key", lambda task_id, url, local_browser=False: task_id
)
monkeypatch.setattr(bt, "_maybe_start_recording", lambda *a, **kw: None)
monkeypatch.setattr(bt, "check_website_access", lambda url: None)
bt.browser_navigate("https://example.com", task_id="task-1")
assert captured["timeout"] == 120