mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:27:14 +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
159 lines
5.2 KiB
Python
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]'
|