Files
hermes-agent/tests/tools/test_browser_open_timeout.py
ethernet 92686159d1 fix(pm): integrate audited runtime and lifecycle repairs
Prepare dependency generations before selecting them. Keep shipped tool
bytes separate from writable additions, and store facts beside their entries.
Validate proposed plugin sets before config publication. Restore the previous
config if the facts write fails.

Consolidate duplicate updater, backup, setup, and voice helpers. Repair
launcher selection, dependency consumers, download ownership, update feeds,
and native Windows process and file handling.

Verification: 206 changed/prior-failing Python files reported 4630 passed,
one failed, and 330 skipped. Fix the remaining Hindsight fixture boundary.
The final targeted rerun reported 234 passed and two skipped. The store
review regression batch reported 83 passed and one skipped. Desktop
TypeScript checks, 56 selected Electron tests, 24 release tests, and the
removed-import/compatibility guards passed.

This is an integration checkpoint, not full audit acceptance. The complete
Python suite has not run on this fixed tree. Crash-atomic plugin publication,
generation cleanup, receipt correlation, and packaged lifecycle acceptance
remain open in docs/pm-audit-status.md.
2026-09-05 22:36:48 -04:00

178 lines
7.7 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():
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
def test_local_install_hint(self, monkeypatch):
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, "", "")
assert "agent-browser install --with-deps" 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