重开仓库内容:改推五个技能(browser-harness / humanizer / humanizer-zh / product-planning / session-mechanism)
按授权清空原有内容后重新提交(原 oil-ui-pro 一并移出,可从历史恢复)。 browser-harness 剔除 .venv 等运行环境;根 .gitignore 补记 .venv/ 与 node_modules/。
This commit is contained in:
1 parent
9a152a1952
commit
777f7fe5d0
240 files changed
+52518
-4409
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,676 @@
|
||||
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_RUNTIME_DIR", None) # shared-tmpdir mode
|
||||
monkeypatch.setattr(admin.ipc, "_RUNTIME", 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_runtime_dir_returns_local_name_when_sock_exists(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(admin.ipc, "IS_WINDOWS", False)
|
||||
monkeypatch.setattr(admin.ipc, "BH_RUNTIME_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(admin.ipc, "BH_RUNTIME_DIR_SHARED", False)
|
||||
monkeypatch.setattr(admin.ipc, "_RUNTIME", 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_runtime_dir_returns_empty_when_sock_missing(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(admin.ipc, "IS_WINDOWS", False)
|
||||
monkeypatch.setattr(admin.ipc, "BH_RUNTIME_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(admin.ipc, "BH_RUNTIME_DIR_SHARED", False)
|
||||
monkeypatch.setattr(admin.ipc, "_RUNTIME", tmp_path)
|
||||
monkeypatch.setattr(admin, "NAME", "session-xyz")
|
||||
|
||||
assert admin._daemon_endpoint_names() == []
|
||||
|
||||
|
||||
def test_daemon_endpoint_names_with_shared_bh_runtime_dir_discovers_named_sockets(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(admin.ipc, "IS_WINDOWS", False)
|
||||
monkeypatch.setattr(admin.ipc, "BH_RUNTIME_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(admin.ipc, "BH_RUNTIME_DIR_SHARED", True)
|
||||
monkeypatch.setattr(admin.ipc, "_RUNTIME", tmp_path)
|
||||
(tmp_path / "bu-default.sock").touch()
|
||||
(tmp_path / "bu-work.sock").touch()
|
||||
(tmp_path / "bu-invalid.name.sock").touch()
|
||||
(tmp_path / "bu.sock").touch() # stale isolated-runtime endpoint
|
||||
|
||||
assert admin._daemon_endpoint_names() == ["default", "work"]
|
||||
|
||||
|
||||
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'), None
|
||||
return FakeSocket(), None
|
||||
|
||||
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'), None
|
||||
return FakeSocket(), None
|
||||
|
||||
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), None))
|
||||
|
||||
assert admin.browser_connections() == [
|
||||
{
|
||||
"name": "default",
|
||||
"page": {"title": "Cat - Wikipedia", "url": "https://en.wikipedia.org/wiki/Cat"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_chrome_running_detects_helium_on_linux(monkeypatch):
|
||||
monkeypatch.setattr("platform.system", lambda: "Linux")
|
||||
monkeypatch.setattr(
|
||||
"subprocess.check_output",
|
||||
lambda *args, **kwargs: "systemd\nhelium\nxdg-desktop-portal\n",
|
||||
)
|
||||
|
||||
assert admin._chrome_running()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path, expected",
|
||||
[
|
||||
("/snap/chromium/1234/usr/lib/chromium-browser/chromium-browser", True),
|
||||
("/SNAP/foo", True),
|
||||
("/usr/bin/google-chrome-stable", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_is_snap_browser(path, expected):
|
||||
assert admin._is_snap_browser(path) == expected
|
||||
|
||||
|
||||
def test_doctor_probe_preserves_snap_bin_env_symlink(monkeypatch, tmp_path):
|
||||
target = tmp_path / "usr" / "bin" / "snap"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("#!/bin/sh\n")
|
||||
snap_bin = tmp_path / "snap" / "bin"
|
||||
snap_bin.mkdir(parents=True)
|
||||
chromium = snap_bin / "chromium"
|
||||
chromium.symlink_to(target)
|
||||
|
||||
monkeypatch.setenv("BH_CHROME_PATH", str(chromium))
|
||||
monkeypatch.delenv("CHROME_PATH", raising=False)
|
||||
|
||||
name, path = admin._doctor_probe_chrome_binary_for_snap()
|
||||
|
||||
assert name == "chromium"
|
||||
assert path == str(chromium)
|
||||
assert admin._is_snap_browser(path)
|
||||
|
||||
|
||||
def test_doctor_probe_preserves_snap_bin_path_symlink(monkeypatch, tmp_path):
|
||||
target = tmp_path / "usr" / "bin" / "snap"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("#!/bin/sh\n")
|
||||
snap_bin = tmp_path / "snap" / "bin"
|
||||
snap_bin.mkdir(parents=True)
|
||||
chromium = snap_bin / "chromium"
|
||||
chromium.symlink_to(target)
|
||||
|
||||
monkeypatch.delenv("BH_CHROME_PATH", raising=False)
|
||||
monkeypatch.delenv("CHROME_PATH", raising=False)
|
||||
|
||||
def fake_which(cmd):
|
||||
return str(chromium) if cmd == "chromium" else None
|
||||
|
||||
monkeypatch.setattr("shutil.which", fake_which)
|
||||
|
||||
name, path = admin._doctor_probe_chrome_binary_for_snap()
|
||||
|
||||
assert name == "chromium"
|
||||
assert path == str(chromium)
|
||||
assert admin._is_snap_browser(path)
|
||||
|
||||
|
||||
def test_run_doctor_prints_snap_detect_on_linux_when_probe_is_snap(monkeypatch, capsys):
|
||||
monkeypatch.setattr(admin, "_version", lambda: "0.1.0")
|
||||
monkeypatch.setattr(admin, "_install_mode", lambda: "git")
|
||||
monkeypatch.setattr(admin, "_chrome_running", lambda: False)
|
||||
monkeypatch.setattr(admin, "daemon_alive", lambda: False)
|
||||
monkeypatch.setattr(admin, "browser_connections", lambda: [])
|
||||
monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0")
|
||||
monkeypatch.setattr(admin, "_doctor_probe_chrome_binary_for_snap", lambda: ("chromium", "/snap/chromium/1/usr/bin/chromium"))
|
||||
monkeypatch.setattr("platform.system", lambda: "Linux")
|
||||
monkeypatch.setattr("shutil.which", lambda _cmd: None)
|
||||
monkeypatch.delenv("BROWSER_USE_API_KEY", raising=False)
|
||||
|
||||
assert admin.run_doctor() == 1
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "[snap-detect]" in out
|
||||
assert "Browser: chromium (snap)" in out
|
||||
assert "Snap confinement prevents CDP binding" in out
|
||||
assert "docs/snap-linux-headless.md" in out
|
||||
|
||||
|
||||
def test_run_doctor_skips_snap_detect_on_non_linux(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: [])
|
||||
monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0")
|
||||
monkeypatch.setattr(admin, "_doctor_probe_chrome_binary_for_snap", lambda: ("chromium", "/snap/chromium/1/usr/bin/chromium"))
|
||||
monkeypatch.setattr("platform.system", lambda: "Darwin")
|
||||
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 "[snap-detect]" not in out
|
||||
|
||||
|
||||
def test_run_doctor_reports_bad_stored_cloud_auth_without_crashing(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: [])
|
||||
monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0")
|
||||
monkeypatch.setattr(admin, "_doctor_probe_chrome_binary_for_snap", lambda: (None, None))
|
||||
monkeypatch.setattr("platform.system", lambda: "Darwin")
|
||||
monkeypatch.setattr(admin.auth, "auth_status", lambda: (_ for _ in ()).throw(admin.auth.AuthError("auth file is not valid JSON")))
|
||||
|
||||
assert admin.run_doctor() == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Browser Use cloud auth" in out
|
||||
assert "auth file is not valid JSON" in out
|
||||
|
||||
|
||||
def test_run_doctor_fix_snap_prints_steps(capsys):
|
||||
assert admin.run_doctor_fix_snap() == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "browser-harness doctor --fix-snap" in out
|
||||
assert "BH_CHROME_PATH" in out
|
||||
assert "google-chrome-stable_current_amd64.deb" in out
|
||||
assert "browser-harness --doctor" in out
|
||||
|
||||
|
||||
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", {}),
|
||||
]
|
||||
|
||||
|
||||
# --- restart_daemon: PID-reuse safety ---
|
||||
|
||||
def test_restart_daemon_does_not_signal_when_daemon_unreachable(monkeypatch, tmp_path):
|
||||
"""If ipc.identify() returns None (daemon gone), restart_daemon must NOT
|
||||
fall back to reading the pid file and SIGTERMing whatever owns that PID —
|
||||
that's the PID-reuse hazard. It should only clean up files."""
|
||||
pid_path = tmp_path / "default.pid"
|
||||
# A pid file with a PID that, if signaled, would hit an unrelated process.
|
||||
# The whole point is that we don't read or trust this number.
|
||||
pid_path.write_text("99999")
|
||||
|
||||
kill_calls = []
|
||||
monkeypatch.setattr(admin.os, "kill", lambda pid, sig: kill_calls.append((pid, sig)))
|
||||
monkeypatch.setattr(admin.ipc, "identify", lambda name, timeout=5.0: None)
|
||||
monkeypatch.setattr(admin.ipc, "ping", lambda name, timeout=1.0: False)
|
||||
monkeypatch.setattr(admin.ipc, "pid_path", lambda name: pid_path)
|
||||
monkeypatch.setattr(admin.ipc, "cleanup_endpoint", lambda name: None)
|
||||
|
||||
# Should not raise, should not signal, should still clean up the pid file.
|
||||
admin.restart_daemon("default")
|
||||
|
||||
assert kill_calls == [], (
|
||||
f"restart_daemon SIGTERM'd a PID despite identify() returning None — "
|
||||
f"this is the PID-reuse hazard the function is meant to avoid. Calls: {kill_calls}"
|
||||
)
|
||||
assert not pid_path.exists(), "stale pid file should be cleaned up"
|
||||
|
||||
|
||||
def test_restart_daemon_signals_pid_returned_by_identify_not_pid_file(monkeypatch, tmp_path):
|
||||
"""The PID we signal must come from the live daemon's self-report, never
|
||||
from the pid file. If a stale pid file disagrees, the live daemon's PID wins."""
|
||||
import signal
|
||||
|
||||
pid_path = tmp_path / "default.pid"
|
||||
pid_path.write_text("99999") # bogus stale value — must be ignored
|
||||
|
||||
live_pid = 4242
|
||||
|
||||
kill_calls = []
|
||||
def fake_kill(pid, sig):
|
||||
kill_calls.append((pid, sig))
|
||||
# First os.kill(pid, 0) probe: report process is gone so we exit the loop
|
||||
# without escalating. We just want to see WHICH pid was probed.
|
||||
if sig == 0:
|
||||
raise ProcessLookupError
|
||||
|
||||
class FakeIPC:
|
||||
def __init__(self):
|
||||
self.shutdown_sent = False
|
||||
def identify(self, name, timeout=5.0):
|
||||
return live_pid
|
||||
def connect(self, name, timeout):
|
||||
return ("conn", "tok")
|
||||
def request(self, conn, tok, msg):
|
||||
if msg.get("meta") == "shutdown":
|
||||
self.shutdown_sent = True
|
||||
return {"ok": True}
|
||||
def pid_path(self, name):
|
||||
return pid_path
|
||||
def cleanup_endpoint(self, name):
|
||||
pass
|
||||
|
||||
fake = FakeIPC()
|
||||
monkeypatch.setattr(admin.os, "kill", fake_kill)
|
||||
monkeypatch.setattr(admin.ipc, "identify", fake.identify)
|
||||
monkeypatch.setattr(admin.ipc, "ping", lambda name, timeout=1.0: True)
|
||||
monkeypatch.setattr(admin.ipc, "connect", fake.connect)
|
||||
monkeypatch.setattr(admin.ipc, "request", fake.request)
|
||||
monkeypatch.setattr(admin.ipc, "pid_path", fake.pid_path)
|
||||
monkeypatch.setattr(admin.ipc, "cleanup_endpoint", fake.cleanup_endpoint)
|
||||
|
||||
admin.restart_daemon("default")
|
||||
|
||||
assert fake.shutdown_sent, "expected shutdown IPC to be sent"
|
||||
assert kill_calls, "expected at least one os.kill probe"
|
||||
pids_signaled = {pid for pid, _ in kill_calls}
|
||||
assert pids_signaled == {live_pid}, (
|
||||
f"restart_daemon must only signal the PID returned by identify(); "
|
||||
f"signaled pids: {pids_signaled}, expected {{{live_pid}}} (and NOT 99999)"
|
||||
)
|
||||
assert not pid_path.exists()
|
||||
|
||||
|
||||
def test_restart_daemon_sends_shutdown_to_pre_upgrade_daemon_without_pid_in_ping(monkeypatch, tmp_path):
|
||||
"""Backward compat: a pre-upgrade daemon's ping reply has {pong:True} but
|
||||
no `pid` field, so identify() returns None. The shutdown IPC must STILL be
|
||||
sent (so the daemon exits cleanly), but no os.kill happens (we have no
|
||||
verified PID to safely signal)."""
|
||||
pid_path = tmp_path / "default.pid"
|
||||
pid_path.write_text("99999") # bogus stale value
|
||||
|
||||
kill_calls = []
|
||||
shutdown_calls = []
|
||||
|
||||
def fake_request(conn, tok, msg):
|
||||
if msg.get("meta") == "shutdown":
|
||||
shutdown_calls.append(msg)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(admin.os, "kill", lambda pid, sig: kill_calls.append((pid, sig)))
|
||||
monkeypatch.setattr(admin.ipc, "identify", lambda name, timeout=5.0: None)
|
||||
monkeypatch.setattr(admin.ipc, "ping", lambda name, timeout=1.0: True) # old daemon: alive but no pid
|
||||
monkeypatch.setattr(admin.ipc, "connect", lambda name, timeout: ("conn", "tok"))
|
||||
monkeypatch.setattr(admin.ipc, "request", fake_request)
|
||||
monkeypatch.setattr(admin.ipc, "pid_path", lambda name: pid_path)
|
||||
monkeypatch.setattr(admin.ipc, "cleanup_endpoint", lambda name: None)
|
||||
|
||||
admin.restart_daemon("default")
|
||||
|
||||
assert shutdown_calls, (
|
||||
"restart_daemon must send shutdown IPC to a pre-upgrade daemon even "
|
||||
"when identify() can't return a PID — otherwise upgrades orphan the "
|
||||
"old daemon while deleting its socket and pid file."
|
||||
)
|
||||
assert kill_calls == [], (
|
||||
f"no os.kill should fire when we don't have a verified PID, "
|
||||
f"but got: {kill_calls}"
|
||||
)
|
||||
assert not pid_path.exists()
|
||||
|
||||
|
||||
def test_restart_daemon_skips_sigterm_if_pid_was_reused_during_wait(monkeypatch, tmp_path):
|
||||
"""A second identify() runs immediately before the SIGTERM. If the daemon
|
||||
exited and the PID was reused mid-wait, identify() will return None (or a
|
||||
different PID) and we must NOT signal — that's the PID-reuse race during
|
||||
the 15s wait window."""
|
||||
import signal
|
||||
|
||||
pid_path = tmp_path / "default.pid"
|
||||
pid_path.write_text("99999")
|
||||
live_pid = 4242
|
||||
|
||||
kill_calls = []
|
||||
|
||||
def fake_kill(pid, sig):
|
||||
kill_calls.append((pid, sig))
|
||||
# All os.kill(pid, 0) probes succeed → loop exhausts → reaches the
|
||||
# SIGTERM branch. (We're simulating a "wedged" daemon that the wait
|
||||
# loop can't tell apart from a daemon whose PID got reused.)
|
||||
|
||||
# First identify() call (top of restart_daemon) returns the live PID.
|
||||
# Second identify() call (right before SIGTERM) returns None — simulating
|
||||
# the daemon having exited and its PID having been reused by an unrelated
|
||||
# process. The function must NOT escalate to SIGTERM in that state.
|
||||
identify_responses = iter([live_pid, None])
|
||||
monkeypatch.setattr(admin.os, "kill", fake_kill)
|
||||
monkeypatch.setattr(admin.ipc, "identify", lambda name, timeout=5.0: next(identify_responses))
|
||||
monkeypatch.setattr(admin.ipc, "ping", lambda name, timeout=1.0: True)
|
||||
monkeypatch.setattr(admin.ipc, "connect", lambda name, timeout: ("conn", "tok"))
|
||||
monkeypatch.setattr(admin.ipc, "request", lambda conn, tok, msg: {"ok": True})
|
||||
monkeypatch.setattr(admin.ipc, "pid_path", lambda name: pid_path)
|
||||
monkeypatch.setattr(admin.ipc, "cleanup_endpoint", lambda name: None)
|
||||
# Speed up the wait loop so the test finishes quickly. The loop polls 75
|
||||
# times at 0.2s = 15s; with sleep neutralized it runs in microseconds.
|
||||
monkeypatch.setattr(admin.time, "sleep", lambda _s: None)
|
||||
|
||||
admin.restart_daemon("default")
|
||||
|
||||
sigterms = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
|
||||
assert sigterms == [], (
|
||||
f"restart_daemon issued SIGTERM despite the re-verify identify() "
|
||||
f"returning None (PID was reused during the 15s wait). Calls: {kill_calls}"
|
||||
)
|
||||
assert not pid_path.exists()
|
||||
|
||||
|
||||
def test_restart_daemon_sigterms_via_start_time_fingerprint_when_socket_gone(monkeypatch, tmp_path):
|
||||
"""Slow-shutdown recovery: the daemon's serve() tears down the IPC socket
|
||||
BEFORE the process exits (the daemon then runs slow cleanup like remote
|
||||
`stop` PATCH calls that can hang). In that window, identify() returns None
|
||||
even though the process is still our daemon. SIGTERM must still fire when
|
||||
the PID's start-time fingerprint hasn't changed since we first identified
|
||||
it — that's strong evidence of "same process, just slow to exit."
|
||||
"""
|
||||
import signal
|
||||
|
||||
pid_path = tmp_path / "default.pid"
|
||||
pid_path.write_text("99999")
|
||||
live_pid = 4242
|
||||
|
||||
kill_calls = []
|
||||
|
||||
def fake_kill(pid, sig):
|
||||
kill_calls.append((pid, sig))
|
||||
# All os.kill(pid, 0) probes succeed; loop exhausts → SIGTERM gate runs.
|
||||
|
||||
# First identify() returns live_pid. Second identify() returns None — the
|
||||
# daemon has torn down its IPC during shutdown but the process is still
|
||||
# finishing up cleanup work, so the start-time fingerprint is unchanged.
|
||||
identify_responses = iter([live_pid, None])
|
||||
# Both _process_start_time() calls return the same fingerprint, signaling
|
||||
# "still the same process." This is the legitimate-slow-shutdown case.
|
||||
monkeypatch.setattr(admin, "_process_start_time", lambda pid: "STARTED_AT_X")
|
||||
monkeypatch.setattr(admin.os, "kill", fake_kill)
|
||||
monkeypatch.setattr(admin.ipc, "identify", lambda name, timeout=5.0: next(identify_responses))
|
||||
monkeypatch.setattr(admin.ipc, "ping", lambda name, timeout=1.0: True)
|
||||
monkeypatch.setattr(admin.ipc, "connect", lambda name, timeout: ("conn", "tok"))
|
||||
monkeypatch.setattr(admin.ipc, "request", lambda conn, tok, msg: {"ok": True})
|
||||
monkeypatch.setattr(admin.ipc, "pid_path", lambda name: pid_path)
|
||||
monkeypatch.setattr(admin.ipc, "cleanup_endpoint", lambda name: None)
|
||||
monkeypatch.setattr(admin.time, "sleep", lambda _s: None)
|
||||
|
||||
admin.restart_daemon("default")
|
||||
|
||||
sigterms = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
|
||||
assert sigterms == [(live_pid, signal.SIGTERM)], (
|
||||
f"slow-shutdown daemon (identify=None but unchanged start-time) must "
|
||||
f"still receive SIGTERM. signal calls: {kill_calls}"
|
||||
)
|
||||
|
||||
|
||||
def test_restart_daemon_skips_sigterm_when_start_time_changed_during_wait(monkeypatch, tmp_path):
|
||||
"""If the start-time fingerprint of the original PID has CHANGED, the PID
|
||||
was reused by another process. Even though identify() also returns None,
|
||||
we must skip SIGTERM — start-time mismatch is the signal that protects
|
||||
against killing an unrelated reused-PID process."""
|
||||
import signal
|
||||
|
||||
pid_path = tmp_path / "default.pid"
|
||||
pid_path.write_text("99999")
|
||||
live_pid = 4242
|
||||
|
||||
kill_calls = []
|
||||
monkeypatch.setattr(admin.os, "kill", lambda pid, sig: kill_calls.append((pid, sig)))
|
||||
|
||||
identify_responses = iter([live_pid, None])
|
||||
# First start-time read at top of restart_daemon: "ORIGINAL".
|
||||
# Second start-time read in the safety gate: "DIFFERENT" — proof of reuse.
|
||||
start_time_responses = iter(["ORIGINAL", "DIFFERENT"])
|
||||
monkeypatch.setattr(admin, "_process_start_time", lambda pid: next(start_time_responses))
|
||||
monkeypatch.setattr(admin.ipc, "identify", lambda name, timeout=5.0: next(identify_responses))
|
||||
monkeypatch.setattr(admin.ipc, "ping", lambda name, timeout=1.0: True)
|
||||
monkeypatch.setattr(admin.ipc, "connect", lambda name, timeout: ("conn", "tok"))
|
||||
monkeypatch.setattr(admin.ipc, "request", lambda conn, tok, msg: {"ok": True})
|
||||
monkeypatch.setattr(admin.ipc, "pid_path", lambda name: pid_path)
|
||||
monkeypatch.setattr(admin.ipc, "cleanup_endpoint", lambda name: None)
|
||||
monkeypatch.setattr(admin.time, "sleep", lambda _s: None)
|
||||
|
||||
admin.restart_daemon("default")
|
||||
|
||||
sigterms = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
|
||||
assert sigterms == [], (
|
||||
f"start-time mismatch indicates PID reuse — restart_daemon must NOT "
|
||||
f"SIGTERM. signal calls: {kill_calls}"
|
||||
)
|
||||
|
||||
|
||||
# --- _process_start_time helper ---
|
||||
|
||||
def test_process_start_time_returns_stable_fingerprint_for_self():
|
||||
"""The start-time of the current process should be readable on Linux,
|
||||
macOS, and Windows, and stable across two reads."""
|
||||
import os as _os, sys
|
||||
if sys.platform.startswith("linux") or sys.platform == "darwin" or sys.platform == "win32":
|
||||
pid = _os.getpid()
|
||||
first = admin._process_start_time(pid)
|
||||
second = admin._process_start_time(pid)
|
||||
assert first is not None, "expected a fingerprint for the current PID"
|
||||
assert first == second, (
|
||||
f"two reads of the same PID should return the same fingerprint; "
|
||||
f"got {first!r} vs {second!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_process_start_time_returns_none_for_invalid_pid():
|
||||
"""Bad inputs (None, 0, negatives, non-int) and PIDs with no live process
|
||||
must return None rather than raising."""
|
||||
for bad in (None, 0, -1, -42, "not-an-int", 1.5, True, False):
|
||||
assert admin._process_start_time(bad) is None, (
|
||||
f"expected None for invalid pid {bad!r}"
|
||||
)
|
||||
# 2**31 - 1 is the largest pid_t; in practice no live process at that PID.
|
||||
assert admin._process_start_time((1 << 31) - 1) is None
|
||||
@@ -0,0 +1,295 @@
|
||||
import asyncio
|
||||
|
||||
from browser_harness import daemon
|
||||
|
||||
|
||||
class _FakeCDP:
|
||||
"""Records send_raw calls so tests can assert which CDP methods fired."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = [] # list of (method, params, session_id)
|
||||
|
||||
async def send_raw(self, method, params=None, session_id=None):
|
||||
self.calls.append((method, params, session_id))
|
||||
# Set-session/initial-attach paths only need a benign response.
|
||||
return {}
|
||||
|
||||
|
||||
def _fresh_daemon():
|
||||
d = daemon.Daemon()
|
||||
d.cdp = _FakeCDP()
|
||||
return d
|
||||
|
||||
|
||||
def test_set_session_enables_all_four_default_domains_on_new_session():
|
||||
"""Regression: switch_tab() / new_tab() in helpers.py route through the
|
||||
`set_session` IPC, which previously only enabled Page on the new
|
||||
session. With Network disabled, wait_for_network_idle() silently stops
|
||||
receiving events after a tab switch. Initial attach enables all four
|
||||
(Page, DOM, Runtime, Network); set_session must enable the same set."""
|
||||
d = _fresh_daemon()
|
||||
new_session = "session-AFTER-switch"
|
||||
|
||||
asyncio.run(d.handle({
|
||||
"meta": "set_session",
|
||||
"session_id": new_session,
|
||||
"target_id": "target-2",
|
||||
}))
|
||||
|
||||
enabled_on_new = [
|
||||
method for (method, _params, sid) in d.cdp.calls
|
||||
if sid == new_session and method.endswith(".enable")
|
||||
]
|
||||
assert set(enabled_on_new) == {"Page.enable", "DOM.enable", "Runtime.enable", "Network.enable"}, (
|
||||
f"set_session must enable Page/DOM/Runtime/Network on the new session "
|
||||
f"(parity with initial attach). Got: {enabled_on_new}"
|
||||
)
|
||||
assert d.session == new_session
|
||||
assert d.target_id == "target-2"
|
||||
|
||||
|
||||
def test_set_session_falls_back_to_existing_target_id_when_not_provided():
|
||||
"""If a caller forgets target_id (passes None), the daemon should keep its
|
||||
existing target_id rather than overwriting it with None — otherwise
|
||||
subsequent calls that depend on self.target_id would break."""
|
||||
d = _fresh_daemon()
|
||||
d.target_id = "original-target"
|
||||
|
||||
asyncio.run(d.handle({
|
||||
"meta": "set_session",
|
||||
"session_id": "session-AFTER",
|
||||
"target_id": None,
|
||||
}))
|
||||
|
||||
assert d.target_id == "original-target"
|
||||
assert d.session == "session-AFTER"
|
||||
|
||||
|
||||
def test_enable_default_domains_swallows_errors_per_domain():
|
||||
"""A single domain failing to enable must not prevent the others from
|
||||
being attempted — that would leave the daemon in a partially-configured
|
||||
state. Each Domain.enable call has its own try/except inside the helper."""
|
||||
class _PartialFailureCDP(_FakeCDP):
|
||||
async def send_raw(self, method, params=None, session_id=None):
|
||||
self.calls.append((method, params, session_id))
|
||||
if method == "DOM.enable":
|
||||
raise RuntimeError("simulated DOM failure")
|
||||
return {}
|
||||
|
||||
d = daemon.Daemon()
|
||||
d.cdp = _PartialFailureCDP()
|
||||
|
||||
asyncio.run(d._enable_default_domains("session-X"))
|
||||
|
||||
attempted = [m for (m, _p, _s) in d.cdp.calls]
|
||||
assert "Page.enable" in attempted
|
||||
assert "DOM.enable" in attempted # attempted, but raised
|
||||
assert "Runtime.enable" in attempted
|
||||
assert "Network.enable" in attempted
|
||||
|
||||
|
||||
def test_set_session_disables_network_on_old_session_before_enabling_new():
|
||||
"""When switching tabs, the previous session's Network domain must be
|
||||
disabled so background tabs (polling, SSE, etc.) stop emitting events
|
||||
into the global buffer that wait_for_network_idle reads. Initial attach
|
||||
has no `old_session` so this disable doesn't fire then."""
|
||||
d = _fresh_daemon()
|
||||
d.session = "session-OLD"
|
||||
d.target_id = "target-OLD"
|
||||
|
||||
asyncio.run(d.handle({
|
||||
"meta": "set_session",
|
||||
"session_id": "session-NEW",
|
||||
"target_id": "target-NEW",
|
||||
}))
|
||||
|
||||
disabled = [
|
||||
(method, sid) for (method, _params, sid) in d.cdp.calls
|
||||
if method == "Network.disable"
|
||||
]
|
||||
assert disabled == [("Network.disable", "session-OLD")], (
|
||||
f"Network.disable must fire on the old session before re-enabling on "
|
||||
f"the new one. Got: {disabled}"
|
||||
)
|
||||
|
||||
# Sanity: the new session still gets Network.enable.
|
||||
enabled_on_new = {
|
||||
method for (method, _p, sid) in d.cdp.calls
|
||||
if sid == "session-NEW" and method.endswith(".enable")
|
||||
}
|
||||
assert "Network.enable" in enabled_on_new
|
||||
|
||||
|
||||
def test_set_session_does_not_disable_network_when_no_previous_session():
|
||||
"""First set_session call (e.g. very early in startup before any attach)
|
||||
has no old_session — the Network.disable path must be skipped."""
|
||||
d = _fresh_daemon()
|
||||
d.session = None # no prior attach
|
||||
|
||||
asyncio.run(d.handle({
|
||||
"meta": "set_session",
|
||||
"session_id": "session-FIRST",
|
||||
"target_id": "target-FIRST",
|
||||
}))
|
||||
|
||||
disables = [m for (m, _p, _s) in d.cdp.calls if m == "Network.disable"]
|
||||
assert disables == [], (
|
||||
f"Network.disable must not fire when there's no previous session "
|
||||
f"to disable. Got: {disables}"
|
||||
)
|
||||
|
||||
|
||||
def test_set_session_runs_disable_and_enables_in_parallel():
|
||||
"""The four Domain.enable calls (plus Network.disable on the old session)
|
||||
must run concurrently via asyncio.gather, not sequentially. With the old
|
||||
sequential code, helpers.switch_tab() would block in _send() for up to
|
||||
~22s on a slow/remote daemon while the helper's IPC socket has a 5s
|
||||
read timeout, causing client-side socket timeouts. Verifying that all
|
||||
five CDP calls reach send_raw before any returns proves parallelization."""
|
||||
class _ConcurrencyProbeCDP:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.in_flight = 0
|
||||
self.max_concurrent = 0
|
||||
self.release = None # asyncio.Event, set inside the test loop
|
||||
|
||||
async def send_raw(self, method, params=None, session_id=None):
|
||||
self.calls.append((method, params, session_id))
|
||||
self.in_flight += 1
|
||||
self.max_concurrent = max(self.max_concurrent, self.in_flight)
|
||||
try:
|
||||
await self.release.wait()
|
||||
finally:
|
||||
self.in_flight -= 1
|
||||
return {}
|
||||
|
||||
async def run():
|
||||
d = daemon.Daemon()
|
||||
d.cdp = _ConcurrencyProbeCDP()
|
||||
d.session = "session-OLD" # ensures Network.disable on old fires
|
||||
d.cdp.release = asyncio.Event()
|
||||
|
||||
handle_task = asyncio.create_task(d.handle({
|
||||
"meta": "set_session",
|
||||
"session_id": "session-NEW",
|
||||
"target_id": "target-NEW",
|
||||
}))
|
||||
# Yield repeatedly until everything that's going to be in-flight is
|
||||
# in-flight. Cap iterations to avoid hanging if parallelization breaks.
|
||||
for _ in range(50):
|
||||
await asyncio.sleep(0)
|
||||
# 5 = Network.disable on OLD + 4 enables on NEW.
|
||||
if d.cdp.in_flight >= 5:
|
||||
break
|
||||
peak = d.cdp.max_concurrent
|
||||
d.cdp.release.set()
|
||||
await handle_task
|
||||
return peak, d.cdp.calls
|
||||
|
||||
peak, calls = asyncio.run(run())
|
||||
assert peak == 5, (
|
||||
f"set_session must run disable + 4 enables concurrently via gather "
|
||||
f"(observed peak in-flight = {peak}; expected 5 = 1 disable on OLD + "
|
||||
f"4 enables on NEW). Sequential await would peak at 1."
|
||||
)
|
||||
# Sanity: the right calls were made.
|
||||
methods = sorted({m for (m, _p, _s) in calls})
|
||||
assert "Network.disable" in methods
|
||||
assert {"Page.enable", "DOM.enable", "Runtime.enable", "Network.enable"}.issubset(methods)
|
||||
|
||||
|
||||
def test_set_session_first_attach_runs_four_enables_in_parallel():
|
||||
"""When there's no previous session, the disable path is skipped — only
|
||||
the four enables run, still in parallel."""
|
||||
class _ConcurrencyProbeCDP:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.in_flight = 0
|
||||
self.max_concurrent = 0
|
||||
self.release = None
|
||||
|
||||
async def send_raw(self, method, params=None, session_id=None):
|
||||
self.calls.append((method, params, session_id))
|
||||
self.in_flight += 1
|
||||
self.max_concurrent = max(self.max_concurrent, self.in_flight)
|
||||
try:
|
||||
await self.release.wait()
|
||||
finally:
|
||||
self.in_flight -= 1
|
||||
return {}
|
||||
|
||||
async def run():
|
||||
d = daemon.Daemon()
|
||||
d.cdp = _ConcurrencyProbeCDP()
|
||||
d.session = None # no previous session
|
||||
d.cdp.release = asyncio.Event()
|
||||
|
||||
handle_task = asyncio.create_task(d.handle({
|
||||
"meta": "set_session",
|
||||
"session_id": "session-FIRST",
|
||||
"target_id": "target-FIRST",
|
||||
}))
|
||||
for _ in range(50):
|
||||
await asyncio.sleep(0)
|
||||
if d.cdp.in_flight >= 4:
|
||||
break
|
||||
peak = d.cdp.max_concurrent
|
||||
d.cdp.release.set()
|
||||
await handle_task
|
||||
return peak
|
||||
|
||||
peak = asyncio.run(run())
|
||||
assert peak == 4, (
|
||||
f"first set_session must run 4 enables concurrently "
|
||||
f"(observed peak = {peak}). No Network.disable should fire."
|
||||
)
|
||||
|
||||
|
||||
def test_current_tab_meta_passes_attached_target_id():
|
||||
"""Regression for issue #304: helpers.current_tab() previously sent
|
||||
Target.getTargetInfo with no targetId. The daemon strips session_id for
|
||||
Target.* methods, so the call hit the browser-level connection with empty
|
||||
params, and Chrome returned info about the *browser* target (empty
|
||||
url/title) instead of the attached page. The daemon now resolves this
|
||||
server-side using its tracked target_id."""
|
||||
class _TargetInfoCDP(_FakeCDP):
|
||||
async def send_raw(self, method, params=None, session_id=None):
|
||||
self.calls.append((method, params, session_id))
|
||||
if method == "Target.getTargetInfo":
|
||||
return {"targetInfo": {
|
||||
"targetId": params["targetId"],
|
||||
"url": "https://example.com/",
|
||||
"title": "Example Domain",
|
||||
"type": "page",
|
||||
}}
|
||||
return {}
|
||||
|
||||
d = daemon.Daemon()
|
||||
d.cdp = _TargetInfoCDP()
|
||||
d.target_id = "page-target-abc"
|
||||
|
||||
result = asyncio.run(d.handle({"meta": "current_tab"}))
|
||||
|
||||
assert result == {
|
||||
"targetId": "page-target-abc",
|
||||
"url": "https://example.com/",
|
||||
"title": "Example Domain",
|
||||
}
|
||||
# The targetId must be passed through — that's the whole point of the fix.
|
||||
get_info_calls = [(p, s) for (m, p, s) in d.cdp.calls if m == "Target.getTargetInfo"]
|
||||
assert get_info_calls == [({"targetId": "page-target-abc"}, None)]
|
||||
|
||||
|
||||
def test_current_tab_meta_returns_not_attached_when_no_target_id():
|
||||
"""Without an attached page, current_tab() has no meaningful answer.
|
||||
Returning {error: not_attached} causes _send() to raise in helpers, which
|
||||
is the right signal for callers like ensure_real_tab() that wrap the call
|
||||
in try/except."""
|
||||
d = _fresh_daemon()
|
||||
d.target_id = None
|
||||
|
||||
result = asyncio.run(d.handle({"meta": "current_tab"}))
|
||||
|
||||
assert result == {"error": "not_attached"}
|
||||
# No CDP call should have been issued.
|
||||
assert d.cdp.calls == []
|
||||
@@ -0,0 +1,352 @@
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from browser_harness import helpers
|
||||
|
||||
|
||||
def _run(fake_png, width, height, **kwargs):
|
||||
fake = lambda method, **_: {"data": fake_png(width, height)}
|
||||
with patch("browser_harness.helpers.cdp", side_effect=fake), tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "shot.png")
|
||||
helpers.capture_screenshot(path, **kwargs)
|
||||
return Image.open(path).size
|
||||
|
||||
|
||||
def test_max_dim_downsizes_oversized_image(fake_png):
|
||||
assert max(_run(fake_png, 4592, 2286, max_dim=1800)) == 1800
|
||||
|
||||
|
||||
def test_max_dim_skips_when_image_already_small(fake_png):
|
||||
assert _run(fake_png, 800, 400, max_dim=1800) == (800, 400)
|
||||
|
||||
|
||||
def test_max_dim_default_is_no_resize(fake_png):
|
||||
assert _run(fake_png, 4592, 2286) == (4592, 2286)
|
||||
|
||||
|
||||
def _seed_skill(tmp_path):
|
||||
site = tmp_path / "domain-skills" / "example"
|
||||
site.mkdir(parents=True)
|
||||
(site / "scraping.md").write_text("hi")
|
||||
|
||||
|
||||
def test_goto_url_omits_domain_skills_by_default(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("BH_DOMAIN_SKILLS", raising=False)
|
||||
monkeypatch.setattr(helpers, "AGENT_WORKSPACE", tmp_path)
|
||||
_seed_skill(tmp_path)
|
||||
with patch("browser_harness.helpers.cdp", return_value={"frameId": "f"}):
|
||||
result = helpers.goto_url("https://www.example.com/")
|
||||
assert result == {"frameId": "f"}
|
||||
|
||||
|
||||
def test_goto_url_includes_domain_skills_when_enabled(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("BH_DOMAIN_SKILLS", "1")
|
||||
monkeypatch.setattr(helpers, "AGENT_WORKSPACE", tmp_path)
|
||||
_seed_skill(tmp_path)
|
||||
with patch("browser_harness.helpers.cdp", return_value={"frameId": "f"}):
|
||||
result = helpers.goto_url("https://www.example.com/")
|
||||
assert result == {"frameId": "f", "domain_skills": ["scraping.md"]}
|
||||
|
||||
|
||||
def test_page_info_raises_clear_error_on_js_exception():
|
||||
def fake_send(req):
|
||||
return {}
|
||||
|
||||
def fake_cdp(method, **kwargs):
|
||||
return {
|
||||
"result": {
|
||||
"type": "object",
|
||||
"subtype": "error",
|
||||
"description": "ReferenceError: location is not defined",
|
||||
},
|
||||
"exceptionDetails": {
|
||||
"text": "Uncaught",
|
||||
"lineNumber": 0,
|
||||
"columnNumber": 16,
|
||||
},
|
||||
}
|
||||
|
||||
with patch("browser_harness.helpers._send", side_effect=fake_send), \
|
||||
patch("browser_harness.helpers.cdp", side_effect=fake_cdp):
|
||||
with pytest.raises(RuntimeError, match="ReferenceError"):
|
||||
helpers.page_info()
|
||||
|
||||
|
||||
# --- fill_input ---
|
||||
|
||||
def test_fill_input_focuses_types_and_fires_events():
|
||||
cdp_calls = []
|
||||
js_calls = []
|
||||
|
||||
def fake_cdp(method, **kwargs):
|
||||
cdp_calls.append((method, kwargs))
|
||||
return {}
|
||||
|
||||
def fake_js(expr, **kwargs):
|
||||
js_calls.append(expr)
|
||||
return True # focus call must return True (element found)
|
||||
|
||||
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp), \
|
||||
patch("browser_harness.helpers.js", side_effect=fake_js):
|
||||
helpers.fill_input("#my-input", "hello")
|
||||
|
||||
assert any("#my-input" in e for e in js_calls)
|
||||
key_downs = [m for m, _ in cdp_calls if m == "Input.dispatchKeyEvent"]
|
||||
assert len(key_downs) > 0
|
||||
assert any("input" in e and "change" in e for e in js_calls)
|
||||
|
||||
|
||||
def test_fill_input_raises_when_element_not_found():
|
||||
def fake_js(expr, **kwargs):
|
||||
return False # element not found
|
||||
|
||||
with patch("browser_harness.helpers.js", side_effect=fake_js):
|
||||
with pytest.raises(RuntimeError, match="element not found"):
|
||||
helpers.fill_input("#missing", "hello")
|
||||
|
||||
|
||||
def test_fill_input_clear_first_sends_select_all_then_backspace():
|
||||
import sys
|
||||
|
||||
key_events = []
|
||||
|
||||
def fake_cdp(method, **kwargs):
|
||||
if method == "Input.dispatchKeyEvent":
|
||||
key_events.append(kwargs)
|
||||
return {}
|
||||
|
||||
def fake_js(expr, **kwargs):
|
||||
return True # element found
|
||||
|
||||
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp), \
|
||||
patch("browser_harness.helpers.js", side_effect=fake_js):
|
||||
helpers.fill_input("#inp", "x", clear_first=True)
|
||||
|
||||
# The "a" must be dispatched with the platform-correct modifier (Meta=4 on
|
||||
# macOS, Ctrl=2 elsewhere). Without the modifier, the field would never get
|
||||
# selected — it would just receive a literal "a".
|
||||
expected_mod = 4 if sys.platform == "darwin" else 2
|
||||
a_events = [e for e in key_events if e.get("key") == "a"]
|
||||
assert a_events, "expected an 'a' key event for select-all"
|
||||
assert all(e.get("modifiers") == expected_mod for e in a_events), \
|
||||
f"select-all 'a' must carry modifiers={expected_mod}; got {[e.get('modifiers') for e in a_events]}"
|
||||
|
||||
# Crucial: no `char` event for the "a" — emitting one makes Chrome treat
|
||||
# Cmd/Ctrl+A as a printable letter instead of a shortcut.
|
||||
assert not any(e.get("type") == "char" and e.get("text") == "a" for e in key_events), \
|
||||
"select-all must not emit a 'char' event with text='a' (would cancel the shortcut)"
|
||||
|
||||
# Backspace still fires (via press_key, which uses keyDown).
|
||||
keys_down = [e.get("key") for e in key_events if e.get("type") in ("keyDown", "rawKeyDown")]
|
||||
assert "Backspace" in keys_down
|
||||
|
||||
|
||||
def test_fill_input_no_clear_skips_ctrl_a():
|
||||
key_events = []
|
||||
|
||||
def fake_cdp(method, **kwargs):
|
||||
if method == "Input.dispatchKeyEvent":
|
||||
key_events.append(kwargs)
|
||||
return {}
|
||||
|
||||
def fake_js(expr, **kwargs):
|
||||
return True # element found
|
||||
|
||||
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp), \
|
||||
patch("browser_harness.helpers.js", side_effect=fake_js):
|
||||
helpers.fill_input("#inp", "x", clear_first=False)
|
||||
|
||||
keys_seen = [e.get("key") for e in key_events if e.get("type") == "keyDown"]
|
||||
assert "Backspace" not in keys_seen
|
||||
|
||||
|
||||
# --- wait_for_element ---
|
||||
|
||||
def test_wait_for_element_returns_true_when_found_immediately():
|
||||
def fake_js(expr, **kwargs):
|
||||
return True
|
||||
|
||||
with patch("browser_harness.helpers.js", side_effect=fake_js):
|
||||
assert helpers.wait_for_element("#target", timeout=2.0) is True
|
||||
|
||||
|
||||
def test_wait_for_element_returns_false_on_timeout():
|
||||
def fake_js(expr, **kwargs):
|
||||
return False
|
||||
|
||||
with patch("browser_harness.helpers.js", side_effect=fake_js), \
|
||||
patch("browser_harness.helpers.time") as mock_time:
|
||||
# simulate time advancing past the deadline immediately
|
||||
start = time.time()
|
||||
mock_time.time.side_effect = [start, start + 5.0]
|
||||
mock_time.sleep = lambda _: None
|
||||
assert helpers.wait_for_element("#missing", timeout=1.0) is False
|
||||
|
||||
|
||||
def test_wait_for_element_visible_uses_check_visibility():
|
||||
js_exprs = []
|
||||
|
||||
def fake_js(expr, **kwargs):
|
||||
js_exprs.append(expr)
|
||||
return True
|
||||
|
||||
with patch("browser_harness.helpers.js", side_effect=fake_js):
|
||||
helpers.wait_for_element("#btn", visible=True)
|
||||
|
||||
# Prefers checkVisibility (walks ancestor chain) with a computed-style
|
||||
# fallback for older Chrome.
|
||||
assert any("checkVisibility" in e for e in js_exprs)
|
||||
assert any("getComputedStyle" in e for e in js_exprs)
|
||||
# must NOT use offsetParent (fails for position:fixed elements)
|
||||
assert not any("offsetParent" in e for e in js_exprs)
|
||||
|
||||
|
||||
def test_wait_for_element_non_visible_uses_simple_check():
|
||||
js_exprs = []
|
||||
|
||||
def fake_js(expr, **kwargs):
|
||||
js_exprs.append(expr)
|
||||
return True
|
||||
|
||||
with patch("browser_harness.helpers.js", side_effect=fake_js):
|
||||
helpers.wait_for_element("#btn", visible=False)
|
||||
|
||||
assert any("querySelector" in e and "offsetParent" not in e for e in js_exprs)
|
||||
|
||||
|
||||
# --- wait_for_network_idle ---
|
||||
|
||||
def test_wait_for_network_idle_returns_true_when_no_events():
|
||||
call_count = 0
|
||||
|
||||
def fake_send(req):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return {"events": []}
|
||||
|
||||
with patch("browser_harness.helpers._send", side_effect=fake_send), \
|
||||
patch("browser_harness.helpers.time") as mock_time:
|
||||
start = 1000.0
|
||||
# first call: not idle yet; second call: idle window elapsed
|
||||
mock_time.time.side_effect = [start, start, start, start + 0.6, start + 0.6]
|
||||
mock_time.sleep = lambda _: None
|
||||
result = helpers.wait_for_network_idle(timeout=5.0, idle_ms=500)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_wait_for_network_idle_waits_for_inflight_request():
|
||||
# Verifies inflight tracking: must not return True until loadingFinished,
|
||||
# even though >idle_ms elapses between requestWillBeSent and loadingFinished.
|
||||
# An event-silence-only implementation would return True at iter2 (wrong).
|
||||
events_seq = [
|
||||
[{"method": "Network.requestWillBeSent", "params": {"requestId": "req1"}}],
|
||||
[], # >500ms elapsed — old impl returns True here; new must NOT
|
||||
[{"method": "Network.loadingFinished", "params": {"requestId": "req1"}}],
|
||||
[], # idle_ms after loadingFinished → return True
|
||||
]
|
||||
idx = 0
|
||||
|
||||
def fake_send(req):
|
||||
nonlocal idx
|
||||
evs = events_seq[min(idx, len(events_seq) - 1)]
|
||||
idx += 1
|
||||
return {"events": evs}
|
||||
|
||||
with patch("browser_harness.helpers._send", side_effect=fake_send), \
|
||||
patch("browser_harness.helpers.time") as mock_time:
|
||||
start = 1000.0
|
||||
# inflight non-empty → short-circuit skips time.time() in idle check for iter1/iter2
|
||||
mock_time.time.side_effect = [
|
||||
start, start, # deadline + last_activity init
|
||||
start + 0.1, # iter1 while-check
|
||||
start + 0.1, # iter1 rWS last_activity update
|
||||
# iter1 idle-check: inflight non-empty → short-circuit
|
||||
start + 0.7, # iter2 while-check (>500ms since rWS but request still in flight)
|
||||
# iter2 idle-check: inflight non-empty → short-circuit
|
||||
start + 0.8, # iter3 while-check
|
||||
start + 0.8, # iter3 lF last_activity update
|
||||
start + 0.8, # iter3 idle-check: 0ms < 500 → not idle
|
||||
start + 1.4, # iter4 while-check
|
||||
start + 1.4, # iter4 idle-check: 600ms >= 500 → True
|
||||
]
|
||||
mock_time.sleep = lambda _: None
|
||||
result = helpers.wait_for_network_idle(timeout=5.0, idle_ms=500)
|
||||
|
||||
assert result is True
|
||||
assert idx == 4 # did not short-circuit at iter2 despite silence > idle_ms
|
||||
|
||||
|
||||
def test_wait_for_network_idle_returns_false_on_timeout():
|
||||
# Continuous rWS keeps inflight non-empty → idle check short-circuits every iteration.
|
||||
# time.time() is only called for while-check and rWS last_activity (not idle check).
|
||||
def fake_send(req):
|
||||
return {"events": [{"method": "Network.requestWillBeSent", "params": {"requestId": "r"}}]}
|
||||
|
||||
with patch("browser_harness.helpers._send", side_effect=fake_send), \
|
||||
patch("browser_harness.helpers.time") as mock_time:
|
||||
start = 1000.0
|
||||
mock_time.time.side_effect = [
|
||||
start, start, # deadline + last_activity init
|
||||
start + 0.1, # iter1 while-check (in deadline)
|
||||
start + 0.1, # iter1 rWS last_activity update
|
||||
# iter1 idle-check: inflight non-empty → short-circuit
|
||||
start + 20.0, # iter2 while-check (past deadline → exit)
|
||||
]
|
||||
mock_time.sleep = lambda _: None
|
||||
result = helpers.wait_for_network_idle(timeout=10.0, idle_ms=500)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
|
||||
def test_wait_for_network_idle_filters_events_to_active_session():
|
||||
"""Background tabs (e.g. a polling page the agent switched away from) keep
|
||||
emitting Network events into the daemon's global buffer. The wait must
|
||||
filter by session_id of the currently-attached tab — otherwise it would
|
||||
see the background tab's traffic and either fail to return idle or wait
|
||||
on the wrong tab's requests."""
|
||||
active = "session-ACTIVE"
|
||||
background = "session-BACKGROUND"
|
||||
|
||||
# First /drain_events/ payload: rWS + lF on the BACKGROUND session that we
|
||||
# must ignore, plus zero events on the active session. With filtering, the
|
||||
# active session sees no traffic and the idle window can elapse.
|
||||
events_seq = [
|
||||
[
|
||||
{"session_id": background, "method": "Network.requestWillBeSent", "params": {"requestId": "bg1"}},
|
||||
{"session_id": background, "method": "Network.loadingFinished", "params": {"requestId": "bg1"}},
|
||||
],
|
||||
[], # second drain — quiet on both sessions; idle window should fire here
|
||||
]
|
||||
drain_idx = 0
|
||||
|
||||
def fake_send(req):
|
||||
nonlocal drain_idx
|
||||
if req.get("meta") == "session":
|
||||
return {"session_id": active}
|
||||
if req.get("meta") == "drain_events":
|
||||
evs = events_seq[min(drain_idx, len(events_seq) - 1)]
|
||||
drain_idx += 1
|
||||
return {"events": evs}
|
||||
return {}
|
||||
|
||||
with patch("browser_harness.helpers._send", side_effect=fake_send), \
|
||||
patch("browser_harness.helpers.time") as mock_time:
|
||||
start = 1000.0
|
||||
# No inflight on active session → idle check uses time.time().
|
||||
mock_time.time.side_effect = [start, start, start, start + 0.6, start + 0.6]
|
||||
mock_time.sleep = lambda _: None
|
||||
result = helpers.wait_for_network_idle(timeout=5.0, idle_ms=500)
|
||||
|
||||
assert result is True, (
|
||||
"wait_for_network_idle must return True even when the BACKGROUND "
|
||||
"session is busy, as long as the ACTIVE session is idle. Without the "
|
||||
"session filter, the background rWS/lF pair would have updated "
|
||||
"last_activity and prevented the idle window from elapsing."
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
from browser_harness import _ipc as ipc
|
||||
|
||||
|
||||
def test_runtime_stem_uses_name_in_shared_runtime_dir(monkeypatch):
|
||||
monkeypatch.setattr(ipc, "BH_RUNTIME_DIR", "/tmp/browser-harness")
|
||||
monkeypatch.setattr(ipc, "BH_RUNTIME_DIR_SHARED", True)
|
||||
|
||||
assert ipc._runtime_stem("work") == "bu-work"
|
||||
|
||||
|
||||
def test_runtime_stem_uses_bare_name_in_isolated_runtime_dir(monkeypatch):
|
||||
monkeypatch.setattr(ipc, "BH_RUNTIME_DIR", "/tmp/browser-harness-work")
|
||||
monkeypatch.setattr(ipc, "BH_RUNTIME_DIR_SHARED", False)
|
||||
|
||||
assert ipc._runtime_stem("work") == "bu"
|
||||
|
||||
|
||||
def test_tmp_stem_uses_name_in_shared_tmp_dir(monkeypatch):
|
||||
monkeypatch.setattr(ipc, "BH_TMP_DIR", "/tmp/browser-harness")
|
||||
monkeypatch.setattr(ipc, "BH_TMP_DIR_SHARED", True)
|
||||
|
||||
assert ipc._tmp_stem("work") == "bu-work"
|
||||
|
||||
|
||||
# --- identify(): ping payload sanitation ---
|
||||
|
||||
class _FakeConn:
|
||||
def close(self): pass
|
||||
|
||||
|
||||
def _patch_identify_response(monkeypatch, response):
|
||||
"""Stub connect() and request() so identify() sees `response` as the JSON
|
||||
parsed from the daemon's reply, exactly as it would arrive over the wire."""
|
||||
monkeypatch.setattr(ipc, "connect", lambda name, timeout=1.0: (_FakeConn(), "tok"))
|
||||
monkeypatch.setattr(ipc, "request", lambda conn, tok, msg: response)
|
||||
|
||||
|
||||
def test_identify_returns_pid_for_well_formed_ping_reply(monkeypatch):
|
||||
_patch_identify_response(monkeypatch, {"pong": True, "pid": 4242})
|
||||
|
||||
assert ipc.identify("default", timeout=0.0) == 4242
|
||||
|
||||
|
||||
def test_identify_rejects_boolean_pid(monkeypatch):
|
||||
"""isinstance(True, int) is True in Python; a hostile or buggy daemon
|
||||
that replies {"pid": True} would otherwise yield PID 1 (init on POSIX),
|
||||
which os.kill(1, SIGTERM) would target. Reject it explicitly."""
|
||||
_patch_identify_response(monkeypatch, {"pong": True, "pid": True})
|
||||
|
||||
assert ipc.identify("default", timeout=0.0) is None
|
||||
|
||||
|
||||
def test_identify_rejects_boolean_false_pid(monkeypatch):
|
||||
"""False is also an int subclass and would yield PID 0."""
|
||||
_patch_identify_response(monkeypatch, {"pong": True, "pid": False})
|
||||
|
||||
assert ipc.identify("default", timeout=0.0) is None
|
||||
|
||||
|
||||
def test_identify_returns_none_when_pid_field_missing(monkeypatch):
|
||||
"""Pre-upgrade daemons reply {pong: True} only — no pid. identify must
|
||||
return None so callers know they have no verified PID to signal, while
|
||||
still letting alive-checks via ipc.ping() succeed."""
|
||||
_patch_identify_response(monkeypatch, {"pong": True})
|
||||
|
||||
assert ipc.identify("default", timeout=0.0) is None
|
||||
|
||||
|
||||
def test_identify_handles_non_dict_ping_payload(monkeypatch):
|
||||
"""request() can deserialize any valid JSON value. A stale or hostile
|
||||
endpoint replying with a list / scalar / null would crash a naive
|
||||
resp.get() with AttributeError; identify must absorb that and return None."""
|
||||
for payload in ([1, 2, 3], "hello", 42, None):
|
||||
_patch_identify_response(monkeypatch, payload)
|
||||
assert ipc.identify("default", timeout=0.0) is None, (
|
||||
f"identify() should reject non-dict ping payload: {payload!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_identify_returns_none_when_pong_is_not_true(monkeypatch):
|
||||
_patch_identify_response(monkeypatch, {"pong": False, "pid": 4242})
|
||||
|
||||
assert ipc.identify("default", timeout=0.0) is None
|
||||
|
||||
|
||||
def test_identify_rejects_zero_and_negative_pids(monkeypatch):
|
||||
"""os.kill semantics on POSIX: pid=0 signals every process in the calling
|
||||
process group; pid=-1 signals every process the caller can; pid<-1 signals
|
||||
the corresponding process group. None of these are valid daemon PIDs and
|
||||
forwarding any of them to os.kill would be catastrophic."""
|
||||
for bad_pid in (0, -1, -42, -99999):
|
||||
_patch_identify_response(monkeypatch, {"pong": True, "pid": bad_pid})
|
||||
assert ipc.identify("default", timeout=0.0) is None, (
|
||||
f"identify() must reject non-positive pid {bad_pid!r}"
|
||||
)
|
||||
|
||||
|
||||
# --- ping(): same payload sanitation ---
|
||||
|
||||
def _patch_ping_response(monkeypatch, response):
|
||||
monkeypatch.setattr(ipc, "connect", lambda name, timeout=1.0: (_FakeConn(), "tok"))
|
||||
monkeypatch.setattr(ipc, "request", lambda conn, tok, msg: response)
|
||||
|
||||
|
||||
def test_ping_returns_true_for_well_formed_pong(monkeypatch):
|
||||
_patch_ping_response(monkeypatch, {"pong": True})
|
||||
|
||||
assert ipc.ping("default", timeout=0.0) is True
|
||||
|
||||
|
||||
def test_ping_handles_non_dict_payload(monkeypatch):
|
||||
"""Same regression class as identify(): if a stale or hostile endpoint
|
||||
replies with a list / scalar / null, ping() must return False rather than
|
||||
raising AttributeError on resp.get(). restart_daemon() now calls ping() on
|
||||
the fallback path, so an unhandled raise here would abort cleanup."""
|
||||
for payload in ([1, 2, 3], "hello", 42, None):
|
||||
_patch_ping_response(monkeypatch, payload)
|
||||
assert ipc.ping("default", timeout=0.0) is False, (
|
||||
f"ping() should reject non-dict payload: {payload!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_ping_returns_false_when_pong_field_is_missing_or_not_true(monkeypatch):
|
||||
for resp in ({}, {"pong": False}, {"pong": "yes"}, {"pong": 1}):
|
||||
_patch_ping_response(monkeypatch, resp)
|
||||
assert ipc.ping("default", timeout=0.0) is False, (
|
||||
f"ping() should require pong is exactly True; got: {resp!r}"
|
||||
)
|
||||
@@ -0,0 +1,254 @@
|
||||
import sys
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_harness import run
|
||||
|
||||
|
||||
def test_stdin_executes_code():
|
||||
stdout = StringIO()
|
||||
fake_stdin = StringIO("print('hello from stdin')")
|
||||
|
||||
with patch.object(sys, "argv", ["browser-harness"]), \
|
||||
patch("browser_harness.run.ensure_daemon"), \
|
||||
patch("browser_harness.run.print_update_banner"), \
|
||||
patch("sys.stdin", fake_stdin), \
|
||||
patch("sys.stdout", stdout):
|
||||
run.main()
|
||||
|
||||
assert stdout.getvalue().strip() == "hello from stdin"
|
||||
|
||||
|
||||
def test_c_flag_is_rejected():
|
||||
with patch.object(sys, "argv", ["browser-harness", "-c", "print('old path')"]), \
|
||||
patch("sys.stdin", StringIO("print('ignored')")):
|
||||
try:
|
||||
run.main()
|
||||
except SystemExit as e:
|
||||
assert "browser-harness <<'PY'" in str(e)
|
||||
else:
|
||||
raise AssertionError("-c should be rejected")
|
||||
|
||||
|
||||
def test_no_args_interactive_stdin_prints_usage():
|
||||
fake_stdin = StringIO("")
|
||||
fake_stdin.isatty = lambda: True
|
||||
|
||||
with patch.object(sys, "argv", ["browser-harness"]), \
|
||||
patch("sys.stdin", fake_stdin):
|
||||
try:
|
||||
run.main()
|
||||
except SystemExit as e:
|
||||
assert "browser-harness <<'PY'" in str(e)
|
||||
else:
|
||||
raise AssertionError("interactive no-args invocation should exit with usage")
|
||||
|
||||
|
||||
def test_no_args_empty_stdin_prints_usage():
|
||||
with patch.object(sys, "argv", ["browser-harness"]), \
|
||||
patch("sys.stdin", StringIO("")):
|
||||
try:
|
||||
run.main()
|
||||
except SystemExit as e:
|
||||
assert "browser-harness <<'PY'" in str(e)
|
||||
else:
|
||||
raise AssertionError("empty stdin should exit with usage")
|
||||
|
||||
|
||||
def test_cloud_bootstrap_on_headless_server(monkeypatch):
|
||||
"""No daemon, no local Chrome, API key + BU_AUTOSPAWN set -> auto-provision cloud daemon."""
|
||||
monkeypatch.setenv("BROWSER_USE_API_KEY", "test-key")
|
||||
monkeypatch.setenv("BU_AUTOSPAWN", "1")
|
||||
with patch.object(sys, "argv", ["browser-harness"]), \
|
||||
patch("sys.stdin", StringIO("x = 1")), \
|
||||
patch("browser_harness.run.daemon_alive", return_value=False), \
|
||||
patch("browser_harness.run._local_chrome_listening", return_value=False), \
|
||||
patch("browser_harness.run.start_remote_daemon") as mock_start, \
|
||||
patch("browser_harness.run.ensure_daemon"), \
|
||||
patch("browser_harness.run.print_update_banner"):
|
||||
run.main()
|
||||
mock_start.assert_called_once()
|
||||
|
||||
|
||||
def test_explicit_bu_cdp_url_blocks_cloud_bootstrap(monkeypatch):
|
||||
"""BU_CDP_URL is documented to override local Chrome discovery (install.md:58-59),
|
||||
so it must also block cloud auto-bootstrap. Otherwise start_remote_daemon would
|
||||
overwrite BU_CDP_WS in the daemon env and silently bill the user for a cloud
|
||||
browser instead of attaching to their explicit endpoint."""
|
||||
monkeypatch.setenv("BU_CDP_URL", "http://127.0.0.1:9333")
|
||||
monkeypatch.setenv("BROWSER_USE_API_KEY", "test-key")
|
||||
monkeypatch.setenv("BU_AUTOSPAWN", "1")
|
||||
with patch.object(sys, "argv", ["browser-harness"]), \
|
||||
patch("sys.stdin", StringIO("x = 1")), \
|
||||
patch("browser_harness.run.daemon_alive", return_value=False), \
|
||||
patch("browser_harness.run._local_chrome_listening", return_value=False), \
|
||||
patch("browser_harness.run.start_remote_daemon") as mock_start, \
|
||||
patch("browser_harness.run.ensure_daemon"), \
|
||||
patch("browser_harness.run.print_update_banner"):
|
||||
run.main()
|
||||
mock_start.assert_not_called()
|
||||
|
||||
|
||||
def test_explicit_bu_cdp_ws_blocks_cloud_bootstrap(monkeypatch):
|
||||
"""Same precedence guarantee for BU_CDP_WS — install.md:58 promises it overrides
|
||||
local Chrome discovery for remote browsers, so cloud auto-bootstrap must defer
|
||||
to the explicit WebSocket endpoint the caller already chose."""
|
||||
monkeypatch.setenv("BU_CDP_WS", "ws://example.test/devtools/browser/abc")
|
||||
monkeypatch.setenv("BROWSER_USE_API_KEY", "test-key")
|
||||
monkeypatch.setenv("BU_AUTOSPAWN", "1")
|
||||
with patch.object(sys, "argv", ["browser-harness"]), \
|
||||
patch("sys.stdin", StringIO("x = 1")), \
|
||||
patch("browser_harness.run.daemon_alive", return_value=False), \
|
||||
patch("browser_harness.run._local_chrome_listening", return_value=False), \
|
||||
patch("browser_harness.run.start_remote_daemon") as mock_start, \
|
||||
patch("browser_harness.run.ensure_daemon"), \
|
||||
patch("browser_harness.run.print_update_banner"):
|
||||
run.main()
|
||||
mock_start.assert_not_called()
|
||||
|
||||
|
||||
def test_empty_bu_cdp_url_does_not_block_bootstrap(monkeypatch):
|
||||
"""An env var set to empty string is conventionally treated as unset; the helper
|
||||
must not let `BU_CDP_URL=""` accidentally suppress cloud bootstrap on the headless
|
||||
fresh-box path #277 explicitly preserved."""
|
||||
monkeypatch.setenv("BU_CDP_URL", "")
|
||||
monkeypatch.setenv("BROWSER_USE_API_KEY", "test-key")
|
||||
monkeypatch.setenv("BU_AUTOSPAWN", "1")
|
||||
with patch.object(sys, "argv", ["browser-harness"]), \
|
||||
patch("sys.stdin", StringIO("x = 1")), \
|
||||
patch("browser_harness.run.daemon_alive", return_value=False), \
|
||||
patch("browser_harness.run._local_chrome_listening", return_value=False), \
|
||||
patch("browser_harness.run.start_remote_daemon") as mock_start, \
|
||||
patch("browser_harness.run.ensure_daemon"), \
|
||||
patch("browser_harness.run.print_update_banner"):
|
||||
run.main()
|
||||
mock_start.assert_called_once()
|
||||
|
||||
|
||||
def test_bad_stored_cloud_auth_does_not_bootstrap_or_crash(monkeypatch):
|
||||
monkeypatch.setenv("BU_AUTOSPAWN", "1")
|
||||
with patch.object(sys, "argv", ["browser-harness"]), \
|
||||
patch("sys.stdin", StringIO("x = 1")), \
|
||||
patch("browser_harness.run.daemon_alive", return_value=False), \
|
||||
patch("browser_harness.run._local_chrome_listening", return_value=False), \
|
||||
patch("browser_harness.run.auth.get_browser_use_api_key", side_effect=run.auth.AuthError("auth file is not valid JSON")), \
|
||||
patch("browser_harness.run.start_remote_daemon") as mock_start, \
|
||||
patch("browser_harness.run.ensure_daemon"), \
|
||||
patch("browser_harness.run.print_update_banner"):
|
||||
run.main()
|
||||
|
||||
mock_start.assert_not_called()
|
||||
|
||||
|
||||
def test_both_bu_cdp_url_and_bu_cdp_ws_set_blocks_bootstrap(monkeypatch):
|
||||
"""When the caller has BOTH endpoints configured (e.g. a parent agent that probes
|
||||
BU_CDP_URL first and falls back to a known BU_CDP_WS), bootstrap must still defer
|
||||
— the user has been doubly explicit about their intent."""
|
||||
monkeypatch.setenv("BU_CDP_URL", "http://127.0.0.1:9333")
|
||||
monkeypatch.setenv("BU_CDP_WS", "ws://example.test/devtools/browser/abc")
|
||||
monkeypatch.setenv("BROWSER_USE_API_KEY", "test-key")
|
||||
monkeypatch.setenv("BU_AUTOSPAWN", "1")
|
||||
with patch.object(sys, "argv", ["browser-harness"]), \
|
||||
patch("sys.stdin", StringIO("x = 1")), \
|
||||
patch("browser_harness.run.daemon_alive", return_value=False), \
|
||||
patch("browser_harness.run._local_chrome_listening", return_value=False), \
|
||||
patch("browser_harness.run.start_remote_daemon") as mock_start, \
|
||||
patch("browser_harness.run.ensure_daemon"), \
|
||||
patch("browser_harness.run.print_update_banner"):
|
||||
run.main()
|
||||
mock_start.assert_not_called()
|
||||
|
||||
|
||||
def test_explicit_endpoint_does_not_break_daemon_alive_short_circuit(monkeypatch):
|
||||
"""daemon_alive=True must continue to short-circuit auto-bootstrap regardless of
|
||||
whether an explicit endpoint is configured — re-using a live daemon was the
|
||||
pre-existing fast path and the precedence guard must not regress it."""
|
||||
monkeypatch.setenv("BU_CDP_URL", "http://127.0.0.1:9333")
|
||||
monkeypatch.setenv("BROWSER_USE_API_KEY", "test-key")
|
||||
monkeypatch.setenv("BU_AUTOSPAWN", "1")
|
||||
with patch.object(sys, "argv", ["browser-harness"]), \
|
||||
patch("sys.stdin", StringIO("x = 1")), \
|
||||
patch("browser_harness.run.daemon_alive", return_value=True), \
|
||||
patch("browser_harness.run._local_chrome_listening", return_value=False), \
|
||||
patch("browser_harness.run.start_remote_daemon") as mock_start, \
|
||||
patch("browser_harness.run.ensure_daemon"), \
|
||||
patch("browser_harness.run.print_update_banner"):
|
||||
run.main()
|
||||
mock_start.assert_not_called()
|
||||
|
||||
|
||||
def test_explicit_endpoint_does_not_break_local_chrome_short_circuit(monkeypatch):
|
||||
"""If a local Chrome is already listening on 9222/9223 the bootstrap must skip
|
||||
even when the user *also* set an explicit endpoint pointing somewhere else.
|
||||
The auto-bootstrap path is for cloud only; routing between local-default and
|
||||
explicit-non-default endpoints is handled later in daemon.py:get_ws_url()."""
|
||||
monkeypatch.setenv("BU_CDP_URL", "http://127.0.0.1:9333")
|
||||
monkeypatch.setenv("BROWSER_USE_API_KEY", "test-key")
|
||||
monkeypatch.setenv("BU_AUTOSPAWN", "1")
|
||||
with patch.object(sys, "argv", ["browser-harness"]), \
|
||||
patch("sys.stdin", StringIO("x = 1")), \
|
||||
patch("browser_harness.run.daemon_alive", return_value=False), \
|
||||
patch("browser_harness.run._local_chrome_listening", return_value=True), \
|
||||
patch("browser_harness.run.start_remote_daemon") as mock_start, \
|
||||
patch("browser_harness.run.ensure_daemon"), \
|
||||
patch("browser_harness.run.print_update_banner"):
|
||||
run.main()
|
||||
mock_start.assert_not_called()
|
||||
|
||||
|
||||
def test_explicit_cdp_configured_helper_truthy(monkeypatch):
|
||||
"""Direct unit test of the helper: any non-empty BU_CDP_URL or BU_CDP_WS must
|
||||
return True so the bootstrap guard reads as 'caller has been explicit'."""
|
||||
for name, value in [
|
||||
("BU_CDP_URL", "http://127.0.0.1:9333"),
|
||||
("BU_CDP_WS", "ws://example.test/devtools/browser/abc"),
|
||||
("BU_CDP_URL", "http://[::1]:9333"), # IPv6 host
|
||||
("BU_CDP_WS", "wss://cloud.example.com/devtools/browser/x"), # secure WS
|
||||
]:
|
||||
monkeypatch.delenv("BU_CDP_URL", raising=False)
|
||||
monkeypatch.delenv("BU_CDP_WS", raising=False)
|
||||
monkeypatch.setenv(name, value)
|
||||
assert run._explicit_cdp_configured() is True, f"{name}={value!r} should be truthy"
|
||||
|
||||
|
||||
def test_explicit_cdp_configured_helper_falsy(monkeypatch):
|
||||
"""Helper must return False for unset, empty-string, or both-unset cases —
|
||||
those are all 'caller has not chosen an endpoint' from the bootstrap's POV."""
|
||||
monkeypatch.delenv("BU_CDP_URL", raising=False)
|
||||
monkeypatch.delenv("BU_CDP_WS", raising=False)
|
||||
assert run._explicit_cdp_configured() is False, "both unset"
|
||||
monkeypatch.setenv("BU_CDP_URL", "")
|
||||
assert run._explicit_cdp_configured() is False, "BU_CDP_URL empty string"
|
||||
monkeypatch.delenv("BU_CDP_URL", raising=False)
|
||||
monkeypatch.setenv("BU_CDP_WS", "")
|
||||
assert run._explicit_cdp_configured() is False, "BU_CDP_WS empty string"
|
||||
|
||||
|
||||
def test_local_chrome_listening_rejects_non_chrome():
|
||||
"""A bare TCP listener on 9222/9223 must not fool the probe — only a real
|
||||
/json/version response counts as Chrome."""
|
||||
with patch("browser_harness.run.urllib.request.urlopen", side_effect=OSError):
|
||||
assert run._local_chrome_listening() is False
|
||||
with patch("browser_harness.run.urllib.request.urlopen") as mock_open:
|
||||
assert run._local_chrome_listening() is True
|
||||
mock_open.assert_called_once()
|
||||
|
||||
|
||||
def test_cli_doctor_fix_snap_invokes_guide():
|
||||
with patch.object(sys, "argv", ["browser-harness", "doctor", "--fix-snap"]), \
|
||||
patch("browser_harness.run.run_doctor_fix_snap", return_value=0) as m:
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
run.main()
|
||||
assert ei.value.code == 0
|
||||
m.assert_called_once()
|
||||
|
||||
|
||||
def test_cli_doctor_rejects_unknown_flags():
|
||||
err = StringIO()
|
||||
with patch.object(sys, "argv", ["browser-harness", "doctor", "--bogus"]), patch("sys.stderr", err):
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
run.main()
|
||||
assert ei.value.code == 2
|
||||
assert "usage" in err.getvalue().lower()
|
||||
@@ -0,0 +1,35 @@
|
||||
from importlib import resources
|
||||
|
||||
|
||||
def _frontmatter(text: str) -> str:
|
||||
assert text.startswith("---\n")
|
||||
end = text.find("\n---\n", 4)
|
||||
assert end != -1
|
||||
return text[4:end]
|
||||
|
||||
|
||||
def test_packaged_skill_frontmatter_is_valid_simple_yaml():
|
||||
text = resources.files("browser_harness").joinpath("SKILL.md").read_text()
|
||||
metadata = {}
|
||||
|
||||
for line in _frontmatter(text).splitlines():
|
||||
key, separator, value = line.partition(":")
|
||||
assert separator == ":", line
|
||||
assert key in {"name", "description"}
|
||||
assert key.strip() == key
|
||||
value = value.strip()
|
||||
assert value, key
|
||||
|
||||
if value[0] in {"'", '"'}:
|
||||
assert value[-1] == value[0], line
|
||||
parsed = value[1:-1]
|
||||
else:
|
||||
parsed = value
|
||||
assert ": " not in parsed, line
|
||||
|
||||
metadata[key] = parsed
|
||||
|
||||
assert metadata == {
|
||||
"name": "browser-harness",
|
||||
"description": "Always use browser-harness for any web interaction: automation, scraping, testing, or site/app work.",
|
||||
}
|
||||
Reference in new issue
Block a user