Files
hermes-agent/tests/tools/test_browser_supervisor_capture.py
Maciej Lisiewski 4278b6aa24 ci: RUF013, RUF015
2026-10-08 21:29:48 +02:00

147 lines
5.3 KiB
Python

"""Behavior contract for ``SUPERVISOR_REGISTRY.capture`` (public CDP seam for trusted plugins).
A real ``CDPSupervisor`` talks to a real local WebSocket server that speaks just enough CDP
(getTargets / attachToTarget / echo). Each connection gets its own page session id
(``S1``, ``S2``, ...) so the tests can tell which socket a frame travelled on.
"""
from __future__ import annotations
import asyncio
import json
import threading
import time
import pytest
from tools.browser_supervisor import _SupervisorRegistry
from tools.browser_supervisor_capture import CapturedCDPInvalid
class _FakeCDP:
def __init__(self) -> None:
self.frames: list[tuple[int, str, str | None]] = [] # (connection no, method, sessionId)
self.connections: list = []
self.url = ""
self.loop = asyncio.new_event_loop()
ready = threading.Event()
threading.Thread(target=self._serve, args=(ready,), daemon=True).start()
assert ready.wait(5)
def _serve(self, ready: threading.Event) -> None:
from websockets.asyncio.server import serve
async def main():
async with serve(self._handle, "127.0.0.1", 0) as server:
self.url = f"ws://127.0.0.1:{next(iter(server.sockets)).getsockname()[1]}/devtools/browser/x"
ready.set()
await asyncio.Event().wait()
self.loop.run_until_complete(main())
async def _handle(self, ws) -> None:
self.connections.append(ws)
conn = len(self.connections)
async for raw in ws:
msg = json.loads(raw)
method, sid = msg["method"], msg.get("sessionId")
self.frames.append((conn, method, sid))
if method == "Test.hang":
continue
if method == "Test.error":
await ws.send(json.dumps({"id": msg["id"], "error": {"code": -32000, "message": "boom"}}))
continue
result = {"echo": method, "sessionId": sid, "params": msg.get("params")}
if method == "Target.getTargets":
result = {"targetInfos": [{"targetId": "T1", "type": "page", "url": "about:blank"}]}
elif method == "Target.attachToTarget":
result = {"sessionId": f"S{conn}"}
await ws.send(json.dumps({"id": msg["id"], "result": result}))
def drop_current(self) -> None:
asyncio.run_coroutine_threadsafe(self.connections[-1].close(), self.loop).result(5)
def methods_on(self, conn: int) -> list[str]:
return [m for c, m, _ in self.frames if c == conn]
@pytest.fixture
def cdp():
return _FakeCDP()
@pytest.fixture
def registry():
reg = _SupervisorRegistry()
yield reg
reg.stop_all()
def _wait(pred, timeout=10.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if pred():
return
time.sleep(0.02)
raise AssertionError("condition not reached")
def test_call_round_trips_on_captured_connection(cdp, registry):
registry.get_or_start("t", cdp.url, start_timeout=5)
handle = registry.capture("t")
assert handle.is_valid() and handle.page_session_id == "S1"
reply = handle.call("Runtime.evaluate", {"expression": "1"}, session_id=handle.page_session_id)
assert reply["result"] == {"echo": "Runtime.evaluate", "sessionId": "S1", "params": {"expression": "1"}}
assert handle.call("Target.getTargets")["result"]["targetInfos"][0]["targetId"] == "T1" # browser-level
with pytest.raises(RuntimeError, match="boom"):
handle.call("Test.error")
started = time.monotonic()
with pytest.raises(TimeoutError):
handle.call("Test.hang", timeout=0.3)
assert time.monotonic() - started < 2
assert handle.is_valid() # neither an error reply nor a timeout invalidates the handle
with pytest.raises(CapturedCDPInvalid):
registry.capture("no-such-task")
def test_handle_never_follows_a_reconnect_or_replacement(cdp, registry):
sup = registry.get_or_start("t", cdp.url, start_timeout=5)
old = registry.capture("t")
# A call in flight when the socket drops fails promptly as invalidation, not at its timeout.
errors: list = []
caller = threading.Thread(target=lambda: errors.append(_raises(lambda: old.call("Test.hang", timeout=30))))
caller.start()
_wait(lambda: "Test.hang" in cdp.methods_on(1))
started = time.monotonic()
cdp.drop_current()
caller.join(10)
assert errors and isinstance(errors[0], CapturedCDPInvalid), errors
assert time.monotonic() - started < 5
_wait(lambda: len(cdp.connections) == 2 and sup.snapshot().active)
assert not old.is_valid()
with pytest.raises(CapturedCDPInvalid):
old.call("Probe.afterReconnect", session_id=old.page_session_id)
assert "Probe.afterReconnect" not in cdp.methods_on(2) # never silently retargeted
fresh = registry.capture("t")
assert fresh.page_session_id == "S2"
assert fresh.call("Probe.fresh", session_id=fresh.page_session_id)["result"]["sessionId"] == "S2"
registry.stop("t") # stop / replacement in the registry invalidates too
assert not fresh.is_valid()
with pytest.raises(CapturedCDPInvalid):
fresh.call("Probe.afterStop")
def _raises(fn) -> BaseException | None:
try:
fn()
except BaseException as exc:
return exc
return None