# Conflicts: # AGENTS.md # acp_adapter/edit_approval.py # acp_adapter/server.py # agent/agent_init.py # agent/anthropic_adapter.py # agent/anthropic_credentials.py # agent/auxiliary_client.py # agent/azure_identity_adapter.py # agent/bedrock_adapter.py # agent/browser_registry.py # agent/chat_completion_helpers.py # agent/coding_context.py # agent/context_references.py # agent/conversation_loop.py # agent/copilot_acp_client.py # agent/credits_tracker.py # agent/curator.py # agent/curator_backup.py # agent/deadline.py # agent/display.py # agent/errors.py # agent/estop.py # agent/i18n.py # agent/image_gen_registry.py # agent/image_routing.py # agent/learning_graph.py # agent/learning_mutations.py # agent/lsp/servers.py # agent/model_metadata.py # agent/models_dev.py # agent/monitoring/gateway_health_export.py # agent/monitoring/otlp_exporter.py # agent/pet/store.py # agent/process_bootstrap.py # agent/prompt_builder.py # agent/proxy_sources/iron_proxy.py # agent/secret_sources/_cache.py # agent/secret_sources/bitwarden.py # agent/secret_sources/registry.py # agent/shell_hooks.py # agent/skill_bundles.py # agent/skill_commands.py # agent/skill_utils.py # agent/ssl_guard.py # agent/ssl_verify.py # agent/system_prompt.py # agent/terminal_env_registry.py # agent/trace_upload.py # agent/transcription_registry.py # agent/tts_registry.py # agent/verify/environment.py # agent/vertex_adapter.py # agent/video_gen_registry.py # agent/web_search_registry.py # cli.py # cron/jobs.py # cron/scheduler.py # gateway/agent_cache_pressure.py # gateway/cgroup_cleanup.py # gateway/channel_directory.py # gateway/config.py # gateway/control_socket.py # gateway/dead_targets.py # gateway/drain_control.py # gateway/hooks.py # gateway/kanban_watchers.py # gateway/lifecycle_ledger.py # gateway/mirror.py # gateway/pairing.py # gateway/platform_registry.py # gateway/platforms/helpers.py # gateway/platforms/weixin.py # gateway/readiness.py # gateway/restart_loop_guard.py # gateway/rich_sent_store.py # gateway/run.py # gateway/session.py # gateway/shutdown_flush.py # gateway/shutdown_forensics.py # gateway/slash_commands.py # gateway/status.py # gateway/sticker_cache.py # gateway/whatsapp_identity.py # hermes_bootstrap.py # hermes_cli/_early_recovery.py # hermes_cli/_install_repair.py # hermes_cli/_startup_fast.py # hermes_cli/_subprocess_compat.py # hermes_cli/agent_plugins.py # hermes_cli/auth.py # hermes_cli/backup.py # hermes_cli/banner.py # hermes_cli/browser_connect.py # hermes_cli/build_info.py # hermes_cli/cli_agent_setup_mixin.py # hermes_cli/cli_commands_mixin.py # hermes_cli/codex_models.py # hermes_cli/config.py # hermes_cli/config_defaults.py # hermes_cli/config_migrations.py # hermes_cli/container_boot.py # hermes_cli/dashboard_auth/registry.py # hermes_cli/debug.py # hermes_cli/dep_ensure.py # hermes_cli/doctor.py # hermes_cli/doctor_live.py # hermes_cli/dump.py # hermes_cli/env_loader.py # hermes_cli/foreign_sessions.py # hermes_cli/gateway.py # hermes_cli/gateway_windows.py # hermes_cli/gui_uninstall.py # hermes_cli/image_provenance.py # hermes_cli/install_identity.py # hermes_cli/kanban.py # hermes_cli/kanban_db.py # hermes_cli/linux_desktop_entry.py # hermes_cli/local_runtime/binaries.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/local_runtime/growth.py # hermes_cli/local_runtime/supervisor.py # hermes_cli/logs.py # hermes_cli/macos_tcc_anchor.py # hermes_cli/main.py # hermes_cli/memory_setup.py # hermes_cli/model_catalog.py # hermes_cli/models.py # hermes_cli/nous_subscription.py # hermes_cli/npm_engine.py # hermes_cli/plugin_index.py # hermes_cli/plugins.py # hermes_cli/plugins_cmd.py # hermes_cli/profile_distribution.py # hermes_cli/profiles.py # hermes_cli/prompt_size.py # hermes_cli/psutil_android.py # hermes_cli/runtime_repair.py # hermes_cli/security_advisories.py # hermes_cli/security_audit.py # hermes_cli/security_audit_startup.py # hermes_cli/service_manager.py # hermes_cli/session_export_md.py # hermes_cli/setup.py # hermes_cli/skills_hub.py # hermes_cli/slack_cli.py # hermes_cli/status.py # hermes_cli/subcommands/gateway.py # hermes_cli/subcommands/uninstall.py # hermes_cli/tools_config.py # hermes_cli/uninstall.py # hermes_cli/update_cmd.py # hermes_cli/update_contract.py # hermes_cli/update_inventory.py # hermes_cli/update_lock.py # hermes_cli/update_receipt.py # hermes_cli/urllib_security.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_routers/profiles.py # hermes_cli/web_routers/skills.py # hermes_cli/web_server.py # hermes_constants.py # hermes_state.py # plugins/disk-cleanup/__init__.py # plugins/disk-cleanup/disk_cleanup.py # plugins/google_meet/node/registry.py # plugins/google_meet/node/server.py # plugins/google_meet/process_manager.py # plugins/google_meet/realtime/openai_client.py # plugins/hermes-achievements/dashboard/plugin_api.py # plugins/memory/hindsight/__init__.py # plugins/memory/honcho/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/honcho/client.py # plugins/memory/honcho/oauth.py # plugins/memory/honcho/session.py # plugins/memory/mem0/__init__.py # plugins/memory/mem0/_setup.py # plugins/memory/openviking/__init__.py # plugins/memory/retaindb/__init__.py # plugins/memory/supermemory/__init__.py # plugins/platforms/a2a/protocol.py # plugins/platforms/dingtalk/adapter.py # plugins/platforms/discord/adapter.py # plugins/platforms/feishu/adapter.py # plugins/platforms/google_chat/adapter.py # plugins/platforms/matrix/adapter.py # plugins/platforms/photon/adapter.py # plugins/platforms/photon/auth.py # plugins/platforms/photon/cli.py # plugins/platforms/slack/adapter.py # plugins/platforms/teams/adapter.py # plugins/platforms/telegram/adapter.py # plugins/platforms/wecom/callback_adapter.py # plugins/platforms/whatsapp/adapter.py # plugins/teams_pipeline/store.py # plugins/video_gen/fal/__init__.py # plugins/web/ddgs/provider.py # plugins/web/exa/provider.py # plugins/web/firecrawl/provider.py # plugins/web/parallel/provider.py # tests/agent/test_ssl_ca_guard.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_cmd_update_apt.py # tests/hermes_cli/test_dashboard_unified_launch.py # tests/hermes_cli/test_dep_ensure.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_live.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_kanban_boards.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_memory_setup_provider_arg.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_pip_install_detection.py # tests/hermes_cli/test_profile_export_credentials.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_tui_npm_install.py # tests/hermes_cli/test_update_fleet_restart_pending.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_ui_build.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/tools/test_browser_chromium_autoinstall.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_lightpanda.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_open_timeout.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_suspect_recycle.py # tests/tools/test_find_shell.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_macos_protected_search.py # tests/tui_gateway/test_compute_host.py # tools/approval.py # tools/blueprints.py # tools/bot_mode_dm.py # tools/bot_mode_probe.py # tools/bot_relay.py # tools/browser_tool.py # tools/browser_use_cli.py # tools/checkpoint_manager.py # tools/code_execution_tool.py # tools/code_kernel.py # tools/computer_use/cua_backend.py # tools/cronjob_tools.py # tools/discord_tool.py # tools/environments/base.py # tools/environments/daytona.py # tools/environments/local.py # tools/environments/modal.py # tools/environments/vercel_sandbox.py # tools/fal_common.py # tools/file_operations.py # tools/lazy_deps.py # tools/mcp_tool.py # tools/neutts_synth.py # tools/process_registry.py # tools/read_extract.py # tools/registry.py # tools/skill_ledger.py # tools/skill_linter.py # tools/skill_manager_tool.py # tools/skill_usage.py # tools/skills_ast_audit.py # tools/skills_guard.py # tools/skills_hub.py # tools/skills_sync.py # tools/skills_sync_client.py # tools/skills_tool.py # tools/terminal_scope.py # tools/terminal_tool.py # tools/tirith_security.py # tools/transcription_tools.py # tools/tts_tool.py # tools/vision_tools.py # tools/voice_mode.py # tools/wake_word.py # tools/web_result_cache.py # tools/website_policy.py # tools/working_diff.py # tools/write_approval.py # tui_gateway/entry.py # tui_gateway/methods_tools.py # tui_gateway/server.py
177 lines
7.7 KiB
Python
177 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("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
|