mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
Drives a Flutter web build in a persistent headless chromium (systemd-run unit, own profile, CDP port) through the semantics tree: tree, tap by label, text, key, scroll, wait, shot, console. Never touches the live display; attaches only to HeadlessChrome. The MCP server wraps the same Driver. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZ4sULGfyav7EEwQ6vhbfg
140 lines
4.8 KiB
Python
140 lines
4.8 KiB
Python
"""Minimal CDP client: find the headless page, call methods, collect events."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import urllib.request
|
|
from collections.abc import Callable
|
|
from typing import Any, Protocol
|
|
|
|
import websocket
|
|
|
|
from flutter_web_drive.errors import EXIT_BROWSER, EXIT_UNSAFE, DriveError
|
|
|
|
CALL_TIMEOUT = 30.0
|
|
|
|
Fetch = Callable[[int, str], Any]
|
|
|
|
|
|
class Socket(Protocol):
|
|
"""The slice of websocket-client's WebSocket the session uses."""
|
|
|
|
def send(self, payload: str) -> Any: ...
|
|
def recv(self) -> Any: ...
|
|
def settimeout(self, timeout: float) -> None: ...
|
|
def close(self) -> None: ...
|
|
|
|
|
|
def http_json(port: int, path: str) -> Any:
|
|
"""GET a DevTools HTTP endpoint on loopback and decode the JSON."""
|
|
with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=2.0) as resp:
|
|
return json.load(resp)
|
|
|
|
|
|
def create_socket(url: str) -> Socket:
|
|
"""Open the page websocket (origin suppressed: chromium rejects foreign ones)."""
|
|
return websocket.create_connection(url, timeout=CALL_TIMEOUT, suppress_origin=True)
|
|
|
|
|
|
class Session:
|
|
"""One request/response CDP conversation over a page websocket."""
|
|
|
|
def __init__(self, ws: Socket) -> None:
|
|
"""Wrap an open websocket."""
|
|
self.ws = ws
|
|
self.next_id = 0
|
|
self.events: list[dict[str, Any]] = []
|
|
|
|
def call(self, method: str, **params: Any) -> dict[str, Any]:
|
|
"""Send one command and return its result; stash events seen meanwhile."""
|
|
self.next_id += 1
|
|
msg_id = self.next_id
|
|
try:
|
|
self.ws.send(json.dumps({"id": msg_id, "method": method, "params": params}))
|
|
while True:
|
|
msg = json.loads(self.ws.recv())
|
|
if msg.get("id") == msg_id:
|
|
break
|
|
self.events.append(msg)
|
|
except (OSError, websocket.WebSocketException) as exc:
|
|
raise DriveError(
|
|
"cdp", f"{method}: connection lost ({exc})", EXIT_BROWSER
|
|
) from exc
|
|
if "error" in msg:
|
|
raise DriveError(
|
|
"cdp", f"{method}: {msg['error'].get('message', msg['error'])}"
|
|
)
|
|
return msg.get("result", {})
|
|
|
|
def evaluate(self, expr: str) -> Any:
|
|
"""Evaluate JS in the page (promises awaited); page exceptions raise."""
|
|
res = self.call(
|
|
"Runtime.evaluate", expression=expr, returnByValue=True, awaitPromise=True
|
|
)
|
|
if "exceptionDetails" in res:
|
|
det = res["exceptionDetails"]
|
|
desc = det.get("exception", {}).get("description") or det.get(
|
|
"text", "exception"
|
|
)
|
|
raise DriveError("eval", desc.splitlines()[0])
|
|
return res.get("result", {}).get("value")
|
|
|
|
def drain(self, seconds: float) -> list[dict[str, Any]]:
|
|
"""Collect queued events until ``seconds`` pass with none; return all seen."""
|
|
self.ws.settimeout(seconds)
|
|
try:
|
|
while True:
|
|
self.events.append(json.loads(self.ws.recv()))
|
|
except (OSError, websocket.WebSocketException):
|
|
pass # the timeout is how a drain ends
|
|
self.ws.settimeout(CALL_TIMEOUT)
|
|
return self.events
|
|
|
|
def close(self) -> None:
|
|
"""Detach; the browser and page keep running."""
|
|
self.ws.close()
|
|
|
|
|
|
def pick_page(targets: list[dict[str, Any]]) -> str:
|
|
"""Websocket URL of the app page: a ``page`` target, preferring non-chrome:// ones."""
|
|
pages = [t for t in targets if t.get("type") == "page"]
|
|
if not pages:
|
|
raise DriveError("connect", "no page target in the browser", EXIT_BROWSER)
|
|
pages.sort(key=lambda t: str(t.get("url", "")).startswith("chrome"))
|
|
return str(pages[0]["webSocketDebuggerUrl"])
|
|
|
|
|
|
def probe(port: int, *, fetch: Fetch = http_json) -> dict[str, Any] | None:
|
|
"""``/json/version`` of whatever listens on ``port``, or None if nothing does."""
|
|
try:
|
|
return dict(fetch(port, "/json/version"))
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def is_headless(version: dict[str, Any]) -> bool:
|
|
"""True only for a headless chromium: the one browser we may send input to."""
|
|
return "HeadlessChrome" in str(version.get("User-Agent", ""))
|
|
|
|
|
|
def connect(
|
|
port: int,
|
|
*,
|
|
fetch: Fetch = http_json,
|
|
create: Callable[[str], Socket] = create_socket,
|
|
) -> Session:
|
|
"""Attach to the headless browser on ``port``; refuse anything with a window."""
|
|
version = probe(port, fetch=fetch)
|
|
if version is None:
|
|
raise DriveError(
|
|
"connect", f"no browser on CDP port {port}; run start first", EXIT_BROWSER
|
|
)
|
|
if not is_headless(version):
|
|
agent = version.get("User-Agent", "?")
|
|
raise DriveError(
|
|
"connect",
|
|
f"CDP port {port} is not headless ({agent}); refusing to drive it",
|
|
EXIT_UNSAFE,
|
|
)
|
|
return Session(create(pick_page(fetch(port, "/json"))))
|