mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 18:07:24 +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
101 lines
3.1 KiB
Python
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"]
|