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.
183 lines
8.0 KiB
Python
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
|