项目文件夹

文件
Alezander9 e52992ad80 fix(daemon): report cdp_disconnected on stale CDP probe in connection_status
When the browser dies but the daemon process keeps running, target_id and session stay cached on the Bridge. Pre-fix, connection_status swallowed the Target.getTargetInfo failure and returned the cached IDs with page=null, so admin counted the daemon as healthy. Now it returns {error: cdp_disconnected} and admin's existing error-check skips it. Also returns {error: not_attached} when target_id is unset.
2026-04-29 13:45:16 -07:00

255 行
9.8 KiB
Python

import pytest
from browser_harness import admin
class FakeSocket:
def __init__(self, response=b'{"target_id":"target-1","session_id":"session-1","page":null}\n'):
self.response = response
self.closed = False
self.sent = b""
def sendall(self, data):
self.sent += data
def recv(self, _size):
out, self.response = self.response, b""
return out
def close(self):
self.closed = True
def test_local_chrome_mode_is_false_when_env_provides_remote_cdp():
assert not admin._is_local_chrome_mode({"BU_CDP_WS": "ws://example.test/devtools/browser/1"})
def test_local_chrome_mode_is_false_when_process_env_provides_remote_cdp(monkeypatch):
monkeypatch.setenv("BU_CDP_WS", "ws://example.test/devtools/browser/1")
assert not admin._is_local_chrome_mode()
def test_handshake_timeout_needs_chrome_remote_debugging_prompt():
msg = "CDP WS handshake failed: timed out during opening handshake"
assert admin._needs_chrome_remote_debugging_prompt(msg)
def test_handshake_403_needs_chrome_remote_debugging_prompt():
msg = "CDP WS handshake failed: server rejected WebSocket connection: HTTP 403"
assert admin._needs_chrome_remote_debugging_prompt(msg)
def test_stale_websocket_does_not_open_chrome_inspect():
msg = "no close frame received or sent"
assert not admin._needs_chrome_remote_debugging_prompt(msg)
def test_daemon_endpoint_names_discovers_valid_socket_names(tmp_path, monkeypatch):
monkeypatch.setattr(admin.ipc, "IS_WINDOWS", False)
monkeypatch.setattr(admin.ipc, "BH_TMP_DIR", None) # shared-tmpdir mode
monkeypatch.setattr(admin.ipc, "_TMP", tmp_path)
(tmp_path / "bu-default.sock").touch()
(tmp_path / "bu-remote_1.sock").touch()
(tmp_path / "bu-invalid.name.sock").touch()
(tmp_path / "not-bu-default.sock").touch()
assert admin._daemon_endpoint_names() == ["default", "remote_1"]
def test_daemon_endpoint_names_with_bh_tmp_dir_returns_local_name_when_sock_exists(tmp_path, monkeypatch):
monkeypatch.setattr(admin.ipc, "IS_WINDOWS", False)
monkeypatch.setattr(admin.ipc, "BH_TMP_DIR", str(tmp_path))
monkeypatch.setattr(admin.ipc, "_TMP", tmp_path)
monkeypatch.setattr(admin, "NAME", "session-xyz")
(tmp_path / "bu.sock").touch()
assert admin._daemon_endpoint_names() == ["session-xyz"]
def test_daemon_endpoint_names_with_bh_tmp_dir_returns_empty_when_sock_missing(tmp_path, monkeypatch):
monkeypatch.setattr(admin.ipc, "IS_WINDOWS", False)
monkeypatch.setattr(admin.ipc, "BH_TMP_DIR", str(tmp_path))
monkeypatch.setattr(admin.ipc, "_TMP", tmp_path)
monkeypatch.setattr(admin, "NAME", "session-xyz")
assert admin._daemon_endpoint_names() == []
def test_active_browser_connections_counts_only_healthy_daemons(monkeypatch):
monkeypatch.setattr(admin, "_daemon_endpoint_names", lambda: ["default", "stale", "remote"])
def fake_connect(name, timeout=1.0):
if name == "stale":
raise ConnectionRefusedError()
if name == "remote":
return FakeSocket(b'{"error":"no close frame received or sent"}\n')
return FakeSocket()
monkeypatch.setattr(admin.ipc, "connect", fake_connect)
assert admin.active_browser_connections() == 1
def test_active_browser_connections_skips_daemons_reporting_cdp_disconnected(monkeypatch):
monkeypatch.setattr(admin, "_daemon_endpoint_names", lambda: ["default", "stale"])
def fake_connect(name, timeout=1.0):
if name == "stale":
return FakeSocket(b'{"error":"cdp_disconnected"}\n')
return FakeSocket()
monkeypatch.setattr(admin.ipc, "connect", fake_connect)
assert admin.active_browser_connections() == 1
def test_browser_connections_returns_attached_page(monkeypatch):
monkeypatch.setattr(admin, "_daemon_endpoint_names", lambda: ["default"])
response = (
b'{"target_id":"target-1","session_id":"session-1",'
b'"page":{"targetId":"target-1","title":"Cat - Wikipedia","url":"https://en.wikipedia.org/wiki/Cat"}}\n'
)
monkeypatch.setattr(admin.ipc, "connect", lambda name, timeout=1.0: FakeSocket(response))
assert admin.browser_connections() == [
{
"name": "default",
"page": {"title": "Cat - Wikipedia", "url": "https://en.wikipedia.org/wiki/Cat"},
}
]
def test_run_doctor_prints_active_browser_connections_and_active_pages(monkeypatch, capsys):
monkeypatch.setattr(admin, "_version", lambda: "0.1.0")
monkeypatch.setattr(admin, "_install_mode", lambda: "git")
monkeypatch.setattr(admin, "_chrome_running", lambda: True)
monkeypatch.setattr(admin, "daemon_alive", lambda: True)
monkeypatch.setattr(admin, "browser_connections", lambda: [
{
"name": "default",
"page": {"title": "Example", "url": "https://example.test"},
},
{
"name": "cats",
"page": {"title": "Cat - Wikipedia", "url": "https://en.wikipedia.org/wiki/Cat"},
},
])
monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0")
monkeypatch.setattr("shutil.which", lambda _cmd: None)
monkeypatch.delenv("BROWSER_USE_API_KEY", raising=False)
assert admin.run_doctor() == 0
out = capsys.readouterr().out
assert "[ok ] active browser connections — 2" in out
assert " default — active page: Example — https://example.test" in out
assert " cats — active page: Cat - Wikipedia — https://en.wikipedia.org/wiki/Cat" in out
def test_doctor_page_output_truncates_long_text(monkeypatch, capsys):
monkeypatch.setattr(admin, "_version", lambda: "0.1.0")
monkeypatch.setattr(admin, "_install_mode", lambda: "git")
monkeypatch.setattr(admin, "_chrome_running", lambda: True)
monkeypatch.setattr(admin, "daemon_alive", lambda: True)
monkeypatch.setattr(admin, "DOCTOR_TEXT_LIMIT", 20)
monkeypatch.setattr(admin, "browser_connections", lambda: [
{
"name": "default",
"page": {"title": "A very long page title", "url": "https://example.test/very/long/path"},
}
])
monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0")
monkeypatch.setattr("shutil.which", lambda _cmd: None)
monkeypatch.delenv("BROWSER_USE_API_KEY", raising=False)
assert admin.run_doctor() == 0
out = capsys.readouterr().out
assert "A very long page ..." in out
assert "https://example.t..." in out
def test_start_remote_daemon_stops_created_browser_when_daemon_start_fails(monkeypatch):
calls = []
browser = {"id": "browser-123", "cdpUrl": "http://127.0.0.1:9333", "liveUrl": "https://live.example"}
def fake_browser_use(path, method, body=None):
calls.append((path, method, body))
if (path, method) == ("/browsers", "POST"):
return browser
if (path, method) == ("/browsers/browser-123", "PATCH"):
return {}
raise AssertionError((path, method, body))
monkeypatch.setattr(admin, "daemon_alive", lambda name: False)
monkeypatch.setattr(admin, "_browser_use", fake_browser_use)
monkeypatch.setattr(admin, "_cdp_ws_from_url", lambda url: "ws://example.test/devtools/browser/1")
monkeypatch.setattr(admin, "ensure_daemon", lambda **kwargs: (_ for _ in ()).throw(RuntimeError("boom")))
with pytest.raises(RuntimeError, match="boom"):
admin.start_remote_daemon()
assert calls == [
("/browsers", "POST", {}),
("/browsers/browser-123", "PATCH", {"action": "stop"}),
]
@pytest.mark.parametrize("exc_type", [KeyboardInterrupt, SystemExit])
def test_start_remote_daemon_stops_created_browser_when_daemon_start_is_interrupted(monkeypatch, exc_type):
calls = []
browser = {"id": "browser-123", "cdpUrl": "http://127.0.0.1:9333", "liveUrl": "https://live.example"}
def fake_browser_use(path, method, body=None):
calls.append((path, method, body))
if (path, method) == ("/browsers", "POST"):
return browser
if (path, method) == ("/browsers/browser-123", "PATCH"):
return {}
raise AssertionError((path, method, body))
monkeypatch.setattr(admin, "daemon_alive", lambda name: False)
monkeypatch.setattr(admin, "_browser_use", fake_browser_use)
monkeypatch.setattr(admin, "_cdp_ws_from_url", lambda url: "ws://example.test/devtools/browser/1")
monkeypatch.setattr(admin, "ensure_daemon", lambda **kwargs: (_ for _ in ()).throw(exc_type()))
with pytest.raises(exc_type):
admin.start_remote_daemon()
assert calls == [
("/browsers", "POST", {}),
("/browsers/browser-123", "PATCH", {"action": "stop"}),
]
@pytest.mark.parametrize("exc_type", [KeyboardInterrupt, SystemExit])
def test_stop_cloud_browser_swallows_baseexception_from_stop_request(monkeypatch, exc_type):
monkeypatch.setattr(admin, "_browser_use", lambda *args, **kwargs: (_ for _ in ()).throw(exc_type()))
admin._stop_cloud_browser("browser-123")
def test_start_remote_daemon_does_not_stop_created_browser_on_success(monkeypatch):
calls = []
browser = {"id": "browser-123", "cdpUrl": "http://127.0.0.1:9333", "liveUrl": "https://live.example"}
def fake_browser_use(path, method, body=None):
calls.append((path, method, body))
if (path, method) == ("/browsers", "POST"):
return browser
raise AssertionError((path, method, body))
monkeypatch.setattr(admin, "daemon_alive", lambda name: False)
monkeypatch.setattr(admin, "_browser_use", fake_browser_use)
monkeypatch.setattr(admin, "_cdp_ws_from_url", lambda url: "ws://example.test/devtools/browser/1")
monkeypatch.setattr(admin, "ensure_daemon", lambda **kwargs: None)
monkeypatch.setattr(admin, "_show_live_url", lambda url: None)
assert admin.start_remote_daemon() == browser
assert calls == [
("/browsers", "POST", {}),
]