utils/flutter_web_drive/tests/test_cdp.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

158 lines
5.0 KiB
Python

"""CDP session, target discovery and the headless safety check."""
from __future__ import annotations
import io
import json
from typing import Any
import pytest
import websocket
from flutter_web_drive import cdp
from flutter_web_drive.errors import EXIT_BROWSER, EXIT_UNSAFE, DriveError
from flutter_web_drive.tests.fakes import (
HEADED,
HEADLESS,
PAGE_TARGETS,
FakePage,
FakeWs,
)
def _fetch(version: dict[str, Any] | None, targets: list[dict[str, Any]]) -> cdp.Fetch:
def fetch(port: int, path: str) -> Any:
if version is None:
raise OSError("refused")
return version if path == "/json/version" else targets
return fetch
def test_call_returns_result_and_collects_events() -> None:
ws = FakeWs(
lambda m, p: {"result": {"ok": m}}, events=[{"method": "Log.entryAdded"}]
)
sess = cdp.Session(ws)
assert sess.call("Log.enable") == {"ok": "Log.enable"}
assert sess.events == [{"method": "Log.entryAdded"}]
assert sess.call("Page.enable", a=1) == {"ok": "Page.enable"}
assert ws.sent[1] == {"id": 2, "method": "Page.enable", "params": {"a": 1}}
def test_call_raises_on_protocol_error() -> None:
sess = cdp.Session(FakeWs(lambda m, p: {"error": {"message": "nope"}}))
with pytest.raises(DriveError, match="Bogus.x: nope"):
sess.call("Bogus.x")
def test_call_wraps_transport_failures_as_browser_errors() -> None:
class Dead(FakeWs):
def send(self, raw: str) -> None:
raise websocket.WebSocketConnectionClosedException("gone")
with pytest.raises(DriveError) as err:
cdp.Session(Dead(lambda m, p: {})).call("Page.enable")
assert err.value.code == EXIT_BROWSER
def test_evaluate_returns_value_or_raises_page_exception() -> None:
page = FakePage(evals={"1+1": 2, "boom()": ValueError("bad")})
sess = cdp.Session(page.ws())
assert sess.evaluate("1+1") == 2
with pytest.raises(DriveError, match="Error: bad"):
sess.evaluate("boom()")
assert (
page.ws().handler("Runtime.evaluate", {"expression": "1+1"})["result"][
"result"
]["value"]
== 2
)
def test_evaluate_exception_without_description_uses_text() -> None:
ws = FakeWs(
lambda m, p: {
"result": {"result": {}, "exceptionDetails": {"text": "Uncaught"}}
}
)
with pytest.raises(DriveError, match="Uncaught"):
cdp.Session(ws).evaluate("x")
def test_drain_collects_until_timeout_and_restores_timeout() -> None:
ws = FakeWs(lambda m, p: {"result": {}})
ws.queue = [{"method": "A"}, {"method": "B"}]
sess = cdp.Session(ws)
assert [e["method"] for e in sess.drain(0.1)] == ["A", "B"]
assert ws.timeout == cdp.CALL_TIMEOUT
def test_close_closes_socket() -> None:
ws = FakeWs(lambda m, p: {})
cdp.Session(ws).close()
assert ws.closed
def test_pick_page_skips_browser_ui_and_needs_a_page() -> None:
assert cdp.pick_page(PAGE_TARGETS) == "ws://page"
chrome_only = [
{"type": "page", "url": "chrome://newtab", "webSocketDebuggerUrl": "ws://n"}
]
assert cdp.pick_page(chrome_only) == "ws://n"
with pytest.raises(DriveError, match="no page target"):
cdp.pick_page([PAGE_TARGETS[0]])
def test_probe_returns_none_when_nothing_listens() -> None:
assert cdp.probe(1, fetch=_fetch(None, [])) is None
assert cdp.probe(1, fetch=_fetch(HEADLESS, [])) == HEADLESS
def test_connect_refuses_headed_browsers() -> None:
with pytest.raises(DriveError) as err:
cdp.connect(9222, fetch=_fetch(HEADED, PAGE_TARGETS), create=lambda u: None)
assert err.value.code == EXIT_UNSAFE
assert "Valve Steam" in err.value.message
def test_connect_reports_missing_browser() -> None:
with pytest.raises(DriveError) as err:
cdp.connect(9, fetch=_fetch(None, []), create=lambda u: None)
assert err.value.code == EXIT_BROWSER
def test_connect_opens_the_page_socket() -> None:
seen: list[str] = []
def create(url: str) -> FakeWs:
seen.append(url)
return FakeWs(lambda m, p: {"result": {}})
sess = cdp.connect(9341, fetch=_fetch(HEADLESS, PAGE_TARGETS), create=create)
assert isinstance(sess, cdp.Session)
assert seen == ["ws://page"]
def test_http_json_reads_loopback(monkeypatch: pytest.MonkeyPatch) -> None:
urls: list[str] = []
def fake_urlopen(url: str, timeout: float) -> io.BytesIO:
urls.append(url)
return io.BytesIO(json.dumps({"ok": True}).encode())
monkeypatch.setattr(cdp.urllib.request, "urlopen", fake_urlopen)
assert cdp.http_json(9341, "/json/version") == {"ok": True}
assert urls == ["http://127.0.0.1:9341/json/version"]
def test_default_create_uses_websocket_client(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[str, dict[str, Any]]] = []
monkeypatch.setattr(
cdp.websocket,
"create_connection",
lambda url, **kw: calls.append((url, kw)) or "ws",
)
assert cdp.create_socket("ws://x") == "ws"
assert calls == [("ws://x", {"timeout": cdp.CALL_TIMEOUT, "suppress_origin": True})]