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

159 lines
5.2 KiB
Python

"""Verbs that act on one attached page."""
from __future__ import annotations
import pytest
from flutter_web_drive import actions, cdp
from flutter_web_drive.errors import EXIT_USAGE, DriveError
from flutter_web_drive.tests.fakes import FakeClock, FakePage, node
TABS = [
node(4, "Library\nTab 1 of 4", rect=(0, 8, 80, 67), flags=("current",)),
node(5, "Browse\nTab 2 of 4", rect=(0, 75, 80, 67)),
node(12, "Library", role="", tag="h2", tappable=False),
]
def _sess(page: FakePage) -> tuple[cdp.Session, FakeClock]:
return cdp.Session(page.ws()), FakeClock()
def test_ensure_semantics_returns_nodes_once_they_appear() -> None:
page = FakePage(nodes=TABS, semantics_on=False)
sess, clock = _sess(page)
clock_sleep = clock.sleep
def flip(seconds: float) -> None:
clock_sleep(seconds)
page.semantics_on = True
clock.sleep = flip # type: ignore[method-assign]
assert [n.id for n in actions.ensure_semantics(sess, clock)] == [4, 5, 12]
def test_ensure_semantics_times_out() -> None:
sess, clock = _sess(FakePage(semantics_on=False))
with pytest.raises(DriveError, match="no Flutter semantics"):
actions.ensure_semantics(sess, clock, timeout=1)
def test_tap_clicks_center_of_best_match_and_reports_ambiguity() -> None:
page = FakePage(nodes=TABS)
sess, clock = _sess(page)
out = actions.tap(sess, clock, "Library")
assert page.clicks == [(40.0, 41.5)]
assert out.startswith("tapped #4 button")
assert "(2 matches; took the first)" in out
assert "matches" not in actions.tap(sess, clock, "browse", contains=True)
def test_tap_unknown_label_lists_what_is_tappable() -> None:
sess, clock = _sess(FakePage(nodes=TABS))
with pytest.raises(
DriveError, match=r'no node labelled "Sources".*"Browse \| Tab 2 of 4"'
):
actions.tap(sess, clock, "Sources")
def test_tap_refuses_offscreen_nodes() -> None:
page = FakePage(nodes=[node(7, "Far", rect=(0, 2000, 10, 10))])
sess, clock = _sess(page)
with pytest.raises(DriveError, match="off-screen"):
actions.tap(sess, clock, "Far")
assert page.clicks == []
def test_tap_xy_sends_move_press_release() -> None:
page = FakePage()
sess = cdp.Session(ws := page.ws())
assert actions.tap_xy(sess, 3, 4) == "tapped (3, 4)"
kinds = [
m["params"]["type"]
for m in ws.sent
if m["method"] == "Input.dispatchMouseEvent"
]
assert kinds == ["mouseMoved", "mousePressed", "mouseReleased"]
def test_insert_text_needs_a_focused_field() -> None:
page = FakePage(focused=False)
sess = cdp.Session(ws := page.ws())
with pytest.raises(DriveError, match="tap the field first"):
actions.insert_text(sess, "x")
page.focused = True
assert actions.insert_text(sess, "secret") == "inserted 6 characters"
assert ws.sent[-1] == {
"id": 3,
"method": "Input.insertText",
"params": {"text": "secret"},
}
def test_press_key_sends_down_and_up_with_text_for_enter() -> None:
page = FakePage()
sess = cdp.Session(ws := page.ws())
assert actions.press_key(sess, "Enter") == "pressed Enter"
down, up = ws.sent
assert down["params"]["type"] == "keyDown"
assert down["params"]["text"] == "\r"
assert up["params"]["type"] == "keyUp"
actions.press_key(sess, "Escape")
assert "text" not in ws.sent[2]["params"]
def test_press_key_rejects_unknown_names() -> None:
sess = cdp.Session(FakePage().ws())
with pytest.raises(DriveError) as err:
actions.press_key(sess, "Hyper")
assert err.value.code == EXIT_USAGE
def test_scroll_defaults_to_viewport_center() -> None:
page = FakePage(viewport=(1000, 800))
sess = cdp.Session(ws := page.ws())
assert actions.scroll(sess, 0, 300) == "scrolled (0, 300) at (500, 400)"
wheel = ws.sent[-1]["params"]
assert (wheel["type"], wheel["x"], wheel["deltaY"]) == ("mouseWheel", 500, 300)
assert actions.scroll(sess, 1, 2, at=(5, 6)) == "scrolled (1, 2) at (5, 6)"
def test_wait_for_present_and_gone() -> None:
page = FakePage(nodes=[])
sess, clock = _sess(page)
sleep = clock.sleep
def appear(seconds: float) -> None:
sleep(seconds)
page.nodes = TABS
clock.sleep = appear # type: ignore[method-assign]
found = actions.wait_for(sess, clock, "Browse", timeout=5, on_timeout=lambda: "x")
assert found.startswith("found #5")
gone = actions.wait_for(
sess, clock, "Sources", timeout=5, gone=True, on_timeout=lambda: "x"
)
assert gone == 'gone: "Sources"'
def test_wait_for_flag_requires_the_flag() -> None:
sess, clock = _sess(FakePage(nodes=TABS))
found = actions.wait_for(
sess, clock, "Library", timeout=1, flag="current", on_timeout=lambda: "x"
)
assert found.startswith("found #4")
with pytest.raises(DriveError, match=r'"Browse" \[current\] not present'):
actions.wait_for(
sess, clock, "Browse", timeout=1, flag="current", on_timeout=lambda: "x"
)
gone = actions.wait_for(
sess,
clock,
"Browse",
timeout=1,
flag="current",
gone=True,
on_timeout=lambda: "x",
)
assert gone == 'gone: "Browse" [current]'