重开仓库内容:改推五个技能(browser-harness / humanizer / humanizer-zh / product-planning / session-mechanism)

按授权清空原有内容后重新提交(原 oil-ui-pro 一并移出,可从历史恢复)。
browser-harness 剔除 .venv 等运行环境;根 .gitignore 补记 .venv/ 与 node_modules/。
This commit is contained in:
admin committed 2026-10-07 16:06:02 +08:00
1 parent 9a152a1952
commit 777f7fe5d0
240 files changed
+52518 -4409

No files matched your search

View File
Whitespace-only changes.
+16
View File
@@ -0,0 +1,16 @@
import base64
import io
import pytest
from PIL import Image
def make_png(width, height):
buf = io.BytesIO()
Image.new("RGB", (width, height), "white").save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode()
@pytest.fixture
def fake_png():
return make_png
Whitespace-only changes.
@@ -0,0 +1,193 @@
from unittest.mock import patch
import math
import pytest
from browser_harness import helpers
def _capture_cdp():
captured = []
def fake_cdp(method, **kwargs):
captured.append((method, kwargs))
return {"result": {"value": None}}
return fake_cdp, captured
def _evaluated_expression(captured):
return next(kw["expression"] for m, kw in captured if m == "Runtime.evaluate")
def _evaluated_expressions(captured):
return [kw["expression"] for m, kw in captured if m == "Runtime.evaluate"]
def _illegal_return_then_success():
captured = []
def fake_cdp(method, **kwargs):
captured.append((method, kwargs))
if method == "Runtime.evaluate" and len(_evaluated_expressions(captured)) == 1:
return {
"result": {"type": "object", "subtype": "error", "description": "SyntaxError: Illegal return statement"},
"exceptionDetails": {"text": "Uncaught SyntaxError: Illegal return statement"},
}
return {"result": {"value": None}}
return fake_cdp, captured
def test_simple_expression_passes_through():
fake_cdp, captured = _capture_cdp()
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp):
helpers.js("document.title")
assert _evaluated_expression(captured) == "document.title"
def test_return_statement_gets_wrapped():
fake_cdp, captured = _illegal_return_then_success()
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp):
helpers.js("const x = 1; return x")
assert _evaluated_expressions(captured) == [
"const x = 1; return x",
"(function(){const x = 1; return x})()",
]
def test_iife_with_internal_return_is_not_double_wrapped():
fake_cdp, captured = _capture_cdp()
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp):
helpers.js("(function(){ return document.title; })()")
assert _evaluated_expression(captured) == "(function(){ return document.title; })()"
def test_js_raises_on_syntax_error_exception_details():
def fake_cdp(method, **kwargs):
return {
"result": {
"type": "object",
"subtype": "error",
"description": "SyntaxError: Invalid or unexpected token",
},
"exceptionDetails": {
"text": "Uncaught",
"lineNumber": 1,
"columnNumber": 12,
},
}
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp):
with pytest.raises(RuntimeError, match="SyntaxError"):
helpers.js('return "a\n\nb";')
def test_js_raises_on_runtime_error_exception_details():
def fake_cdp(method, **kwargs):
return {
"result": {
"type": "object",
"subtype": "error",
"description": "ReferenceError: missing is not defined",
},
"exceptionDetails": {
"text": "Uncaught",
"lineNumber": 0,
"columnNumber": 17,
},
}
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp):
with pytest.raises(RuntimeError, match="ReferenceError"):
helpers.js("return missing.value")
def test_js_raises_on_error_result_without_exception_details():
def fake_cdp(method, **kwargs):
return {
"result": {
"type": "object",
"subtype": "error",
"description": "Error: evaluation failed",
}
}
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp):
with pytest.raises(RuntimeError, match="evaluation failed"):
helpers.js("throw new Error('evaluation failed')")
def test_return_word_inside_string_does_not_trigger_wrapping():
fake_cdp, captured = _capture_cdp()
expr = 'document.body.innerText.includes("return ")'
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp):
helpers.js(expr)
assert _evaluated_expression(captured) == expr
def test_return_word_inside_comment_does_not_trigger_wrapping():
fake_cdp, captured = _capture_cdp()
expr = "// return comment\n1 + 1"
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp):
helpers.js(expr)
assert _evaluated_expression(captured) == expr
@pytest.mark.parametrize("expr", ["return\t1", "return\n1"])
def test_top_level_return_with_whitespace_gets_wrapped(expr):
fake_cdp, captured = _illegal_return_then_success()
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp):
helpers.js(expr)
assert _evaluated_expressions(captured) == [expr, f"(function(){{{expr}}})()"]
@pytest.mark.parametrize(
("unserializable", "expected"),
[
("NaN", math.nan),
("Infinity", math.inf),
("-Infinity", -math.inf),
("-0", -0.0),
("1n", 1),
],
)
def test_js_returns_unserializable_values(unserializable, expected):
def fake_cdp(method, **kwargs):
return {"result": {"type": "number", "unserializableValue": unserializable, "description": unserializable}}
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp):
value = helpers.js(unserializable)
if isinstance(expected, float) and math.isnan(expected):
assert math.isnan(value)
elif expected == 0:
assert value == 0
assert math.copysign(1, value) == math.copysign(1, expected)
else:
assert value == expected
def test_js_primitive_exception_message_uses_exception_value():
def fake_cdp(method, **kwargs):
return {
"result": {"type": "string", "value": "boom"},
"exceptionDetails": {
"text": "Uncaught",
"lineNumber": 0,
"columnNumber": 9,
"exception": {"type": "string", "value": "boom"},
},
}
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp):
with pytest.raises(RuntimeError, match="boom"):
helpers.js("throw value")
def test_js_timeout_error_includes_expression_context():
def fake_cdp(method, **kwargs):
raise TimeoutError("timed out")
with patch("browser_harness.helpers.cdp", side_effect=fake_cdp):
with pytest.raises(RuntimeError, match="Runtime.evaluate.*document.title"):
helpers.js("document.title")
Whitespace-only changes.
+676
View File
@@ -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
+295
View File
@@ -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 == []
+352
View File
@@ -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."
)
+128
View File
@@ -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}"
)
+254
View File
@@ -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()
+35
View File
@@ -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.",
}