utils/flutter_web_drive/tests/fakes.py
Krzysztof kuhy Rudnicki f93a24e283 flutter_web_drive: headless CDP driver + MCP server for Flutter web apps
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
2026-10-24 22:37:49 +02:00

180 lines
5.8 KiB
Python

"""In-memory stand-ins for a headless chromium page and its CDP websocket."""
from __future__ import annotations
import json
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
import websocket
from flutter_web_drive import semantics
Handler = Callable[[str, dict[str, Any]], dict[str, Any]]
def node(
nid: int,
label: str = "",
*,
role: str = "button",
value: str = "",
rect: tuple[int, int, int, int] = (0, 0, 80, 40),
tappable: bool = True,
flags: tuple[str, ...] = (),
tag: str = "flt-semantics",
) -> dict[str, Any]:
"""Build one raw node record the way COLLECT_JS returns it."""
x, y, w, h = rect
return {
"id": nid,
"tag": tag,
"role": role,
"label": label,
"value": value,
"x": x,
"y": y,
"w": w,
"h": h,
"tappable": tappable,
"flags": list(flags),
}
class FakeWs:
"""A websocket whose replies come from a handler, with queued events."""
def __init__(
self, handler: Handler, events: list[dict[str, Any]] | None = None
) -> None:
"""Answer each request with ``handler``; replay ``events`` on enable."""
self.handler = handler
self.events = list(events or [])
self.sent: list[dict[str, Any]] = []
self.queue: list[dict[str, Any]] = []
self.timeout: float | None = None
self.closed = False
def send(self, raw: str) -> None:
"""Record the request and queue its reply (events first on enable)."""
msg = json.loads(raw)
self.sent.append(msg)
if msg["method"] in {"Runtime.enable", "Log.enable"}:
self.queue.extend(self.events)
self.events = []
self.queue.append(
{"id": msg["id"], **self.handler(msg["method"], msg["params"])}
)
def recv(self) -> str:
"""Pop the next queued message, or time out like the real client."""
if not self.queue:
raise websocket.WebSocketTimeoutException("timed out")
return json.dumps(self.queue.pop(0))
def settimeout(self, seconds: float) -> None:
"""Remember the timeout so tests can assert drain restored it."""
self.timeout = seconds
def close(self) -> None:
"""Mark closed."""
self.closed = True
@dataclass
class FakePage:
"""A Flutter web page as seen through Runtime.evaluate and Input.*."""
nodes: list[dict[str, Any]] = field(default_factory=list)
semantics_on: bool = True
flutter_ready: bool = True
focused: bool = True
href: str = "http://127.0.0.1:1/"
viewport: tuple[int, int] = (1400, 900)
stats: dict[str, Any] = field(
default_factory=lambda: {"w": 1400, "h": 900, "colours": 180, "stddev": 9.0},
)
evals: dict[str, Any] = field(default_factory=dict)
clicks: list[tuple[float, float]] = field(default_factory=list)
def evaluate(self, expr: str) -> dict[str, Any]:
"""Answer the driver's known JS snippets; fall back to ``evals``."""
value: Any
if expr == semantics.ENABLE_JS:
value = "on" if self.semantics_on else "missing"
elif expr == semantics.COLLECT_JS:
value = self.nodes if self.semantics_on else []
elif expr == semantics.FLUTTER_READY_JS:
value = self.flutter_ready
elif expr == semantics.FOCUS_JS:
value = self.focused
elif expr == semantics.VIEWPORT_JS:
value = list(self.viewport)
elif expr == "location.href":
value = self.href
elif expr.startswith(semantics.IMAGE_STATS_FN):
value = self.stats
elif expr in self.evals:
value = self.evals[expr]
if isinstance(value, Exception):
return {
"result": {
"result": {"type": "object"},
"exceptionDetails": {
"text": "Uncaught",
"exception": {"description": f"Error: {value}\n at x"},
},
}
}
else: # pragma: no cover - a test sent JS the fake does not model
raise AssertionError(f"unexpected evaluate: {expr[:80]}")
return {"result": {"result": {"type": "object", "value": value}}}
def handle(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
"""CDP handler for FakeWs."""
if method == "Runtime.evaluate":
return self.evaluate(params["expression"])
if method == "Page.captureScreenshot":
return {"result": {"data": "iVBORw0KGgo="}}
if method == "Input.dispatchMouseEvent" and params["type"] == "mouseReleased":
self.clicks.append((params["x"], params["y"]))
return {"result": {}}
def ws(self, events: list[dict[str, Any]] | None = None) -> FakeWs:
"""A fresh websocket onto this page."""
return FakeWs(self.handle, events)
class FakeClock:
"""Deterministic monotonic clock; sleeping advances it."""
def __init__(self) -> None:
"""Start at t=0."""
self.t = 0.0
self.slept: list[float] = []
def now(self) -> float:
"""Current fake time."""
return self.t
def sleep(self, seconds: float) -> None:
"""Advance fake time."""
self.slept.append(seconds)
self.t += seconds
HEADLESS = {
"Browser": "Chrome/155",
"User-Agent": "Mozilla/5.0 HeadlessChrome/155.0.0.0",
}
HEADED = {"Browser": "Chrome/126", "User-Agent": "Mozilla/5.0 Valve Steam Client"}
PAGE_TARGETS = [
{
"type": "browser_ui",
"url": "chrome://omnibox",
"webSocketDebuggerUrl": "ws://ui",
},
{"type": "page", "url": "http://127.0.0.1:1/", "webSocketDebuggerUrl": "ws://page"},
]