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

101 lines
3.1 KiB
Python

"""MCP server: same verbs as the CLI, delegating to Driver."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
import pytest
from mcp.server.mcpserver.exceptions import ToolError
from flutter_web_drive import mcp_server
from flutter_web_drive.errors import DriveError
VERBS = {
"start",
"stop",
"shot",
"tree",
"tap",
"tap_xy",
"text",
"key",
"scroll",
"wait",
"eval_js",
"console",
}
class Fake:
"""Driver stand-in: echoes the call, fails on 'stop'."""
def __init__(self, app: str, *, cdp_port: int | None = None) -> None:
self.app = app
def __getattr__(self, name: str) -> Any:
def verb(*args: Any, **kw: Any) -> str:
if name == "stop":
raise DriveError("stop", "boom")
return f"{self.app}:{name}:{args}:{sorted(kw)}"
return verb
@pytest.fixture(autouse=True)
def _fake(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(mcp_server, "Driver", Fake)
def _call(tool: str, **args: Any) -> str:
result = asyncio.run(mcp_server.server.call_tool(tool, args))
return result.content[0].text # type: ignore[union-attr]
def test_tools_cover_every_verb() -> None:
tools = asyncio.run(mcp_server.server.list_tools())
assert {t.name for t in tools} == VERBS
def test_tools_delegate_to_driver() -> None:
assert _call("tap", label="Browse", app="k") == "k:tap:('Browse',):['contains']"
assert _call("start", url="u", profile="/p", cdp_port=1).startswith(
"default:start:"
)
assert _call("shot").startswith("default:shot:(None,)")
assert _call("shot", out="/x.png").startswith("default:shot:(PosixPath('/x.png'),)")
assert _call("tree").startswith("default:tree:")
assert _call("tap_xy", x=1, y=2).startswith("default:tap_xy:(1.0, 2.0)")
assert _call("text", value="v").startswith("default:text:('v',)")
assert _call("key", name="Enter").startswith("default:key:")
assert _call("scroll", dx=0, dy=1).startswith("default:scroll:")
assert ":scroll:" in _call("scroll", dx=0, dy=1, at_x=2, at_y=3)
assert _call("wait", label="L").startswith("default:wait:")
assert _call("eval_js", expr="1").startswith("default:eval:")
assert _call("console").startswith("default:console:")
def test_text_from_file_keeps_secret_out_of_arguments(tmp_path: Path) -> None:
secret = tmp_path / "pw"
secret.write_text("hunter2\n")
assert _call("text", from_file=str(secret)) == "default:text:('hunter2',):[]"
with pytest.raises(ToolError, match="cannot read"):
_call("text", from_file=str(tmp_path / "missing"))
with pytest.raises(ToolError, match="value or from_file"):
_call("text")
def test_drive_errors_surface_as_tool_errors() -> None:
with pytest.raises(ToolError, match="stop failed: boom"):
_call("stop")
def test_main_runs_stdio(monkeypatch: pytest.MonkeyPatch) -> None:
seen: list[str] = []
monkeypatch.setattr(
mcp_server.server, "run", lambda transport: seen.append(transport)
)
mcp_server.main()
assert seen == ["stdio"]