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.
178 lines
7.7 KiB
Python
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
|