Files
wehub-resource-sync 91e75e620b
CI: cua-driver distro-compat matrix / Resolve release version (push) Waiting to run
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / Distro compat summary (push) Blocked by required conditions
CI: Nix Linux Rust source / Nix / compositor build (push) Waiting to run
CI: Nix Linux Rust source / Nix / driver package (push) Waiting to run
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Waiting to run
CI: Rust Linux unit / Rust Linux unit and compile (push) Waiting to run
CI: Rust Windows unit / Rust Windows unit and compile (push) Waiting to run
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Waiting to run
CD: Docs MCP Server / build (linux/amd64) (push) Waiting to run
CD: Docs MCP Server / build (linux/arm64) (push) Waiting to run
CD: Docs MCP Server / merge (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

477 lines
18 KiB
Python

"""
Android multi-touch integration tests.
Verifies the full touch action space — single-touch gestures, SDK pinch helpers,
and true two-finger MT Protocol B injection — against the TouchTest APK running
on an Android VM.
Layout
──────
TestAndroidMultitouchLocal bare-metal AndroidEmulatorRuntime via Sandbox.ephemeral()
TestAndroidMultitouchCloud cloud Android VM via Sandbox.ephemeral()
Usage
─────
# Local only (default):
pytest tests/test_android_multitouch.py -v
# Cloud (once implemented):
CUA_TEST_API_KEY=sk-... pytest tests/test_android_multitouch.py -v -k cloud
Environment variables
─────────────────────
CUA_ANDROID_TEST_APK path or URL to a pre-built TouchTest debug APK
default: latest release from
https://github.com/trycua/android-touch-test-app
CUA_TEST_API_KEY Anthropic API key (cloud tests only)
CUA_ANDROID_API_LEVEL Android API level to boot (default: 34)
CUA_ANDROID_AVD_NAME AVD name (default: cua-multitouch-test)
APK source
──────────
The TouchTest APK is built and released from:
https://github.com/trycua/android-touch-test-app
Latest release download:
https://github.com/trycua/android-touch-test-app/releases/latest/download/app-debug.apk
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import urllib.request
from pathlib import Path
import pytest
import pytest_asyncio
from cua_sandbox.image import Image
from cua_sandbox.runtime.android_emulator import AndroidEmulatorRuntime
from cua_sandbox.runtime.compat import skip_if_unsupported
from cua_sandbox.sandbox import Sandbox
# ── Config ─────────────────────────────────────────────────────────────────
_APK_RELEASE_URL = (
"https://github.com/trycua/android-touch-test-app" "/releases/latest/download/app-debug.apk"
)
_APK_ENV = os.environ.get("CUA_ANDROID_TEST_APK", "")
# If env var is a local path use it directly; otherwise download from releases.
_APK_PATH = Path(_APK_ENV) if _APK_ENV and not _APK_ENV.startswith("http") else None
_APK_PACKAGE = "com.cuatest.touchtest"
_APK_ACTIVITY = "com.cuatest.touchtest/.MainActivity"
_LOG_TAG = "TouchTest"
_RESET_ACTION = "com.cuatest.touchtest.RESET_LOG"
_API_LEVEL = int(os.environ.get("CUA_ANDROID_API_LEVEL", "34"))
_AVD_NAME = os.environ.get("CUA_ANDROID_AVD_NAME", "cua-multitouch-test")
_API_KEY = os.environ.get("CUA_TEST_API_KEY")
_SETTLE_S = 0.6
_LAUNCH_WAIT_S = 3.0
# ── Helpers ────────────────────────────────────────────────────────────────
def _get_apk() -> Path:
"""Return path to the TouchTest APK, downloading from GitHub Releases if needed."""
if _APK_PATH is not None:
if _APK_PATH.exists():
return _APK_PATH
raise FileNotFoundError(f"CUA_ANDROID_TEST_APK set but file not found: {_APK_PATH}")
# Download from latest release
cache = Path("/tmp/cua-touch-test-app-debug.apk")
if not cache.exists():
url = os.environ.get("CUA_ANDROID_TEST_APK", _APK_RELEASE_URL)
print(f"\n[setup] Downloading TouchTest APK from {url} …")
urllib.request.urlretrieve(url, cache)
return cache
def _parse_touch_events(logcat_stdout: str) -> list[dict]:
events = []
for line in logcat_stdout.splitlines():
m = re.search(r"TouchTest:\s+(\{.*\})", line)
if m:
try:
obj = json.loads(m.group(1))
if "action" in obj:
events.append(obj)
except json.JSONDecodeError:
pass
return events
def _max_pointer_count(events: list[dict]) -> int:
return max((e.get("pointer_count", 0) for e in events), default=0)
def _has_action(events: list[dict], action: str) -> bool:
return any(e.get("action") == action for e in events)
async def _read_events(sb: Sandbox) -> list[dict]:
result = await sb.shell.run(f"logcat -d -s {_LOG_TAG}", timeout=10)
return _parse_touch_events(result.stdout)
async def _reset(sb: Sandbox) -> None:
await sb.shell.run("logcat -c")
await sb.shell.run(f"am broadcast -a {_RESET_ACTION}")
await asyncio.sleep(0.2)
# ── Session-scoped event loop (required for session-scoped async fixtures) ──
@pytest_asyncio.fixture(scope="session")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
# ── Session fixture: one emulator for the entire local test run ─────────────
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def local_android_sb():
"""
Boot a single AndroidEmulatorRuntime via Sandbox.ephemeral(), install the TouchTest
APK, escalate to root, launch the app, and yield the ready Sandbox.
Shared across all local tests so we only pay the boot cost once.
The compatibility gate lives here (not in the autouse reset fixture) so that
cloud tests, which have their own API-key gate and don't depend on a local
emulator, are not affected when local Android support is absent.
"""
skip_if_unsupported(Image.android(str(_API_LEVEL)))
apk = _get_apk()
runtime = AndroidEmulatorRuntime(
api_level=_API_LEVEL,
memory_mb=4096,
headless=True,
no_boot_anim=True,
)
image = Image.android(str(_API_LEVEL)).apk_install(str(apk))
async with Sandbox.ephemeral(image, runtime=runtime, name=_AVD_NAME) as sb:
# Launch app
await sb.shell.run(f"am start -n {_APK_ACTIVITY}")
await asyncio.sleep(_LAUNCH_WAIT_S)
yield sb
@pytest_asyncio.fixture(autouse=True)
async def _reset_between_tests(request):
"""Clear logcat + app event counter before every local test.
Resolves local_android_sb lazily so that cloud tests (which don't request
the local emulator fixture) don't trigger emulator boot or the compat skip.
"""
if "local_android_sb" not in request.fixturenames:
yield
return
sb = request.getfixturevalue("local_android_sb")
await _reset(sb)
yield
# ══════════════════════════════════════════════════════════════════════════════
# Shared test logic (mixin)
# ══════════════════════════════════════════════════════════════════════════════
class _MultitouchTests:
"""
Full touch action suite. Subclasses must expose a ``sb`` fixture that
yields a ready Sandbox with the TouchTest APK installed and running.
"""
# ── Single-touch ──────────────────────────────────────────────────────
async def test_tap(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.tap(w // 2, h // 2)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No touch events received"
assert _has_action(te, "ACTION_DOWN")
assert _has_action(te, "ACTION_UP")
assert _max_pointer_count(te) == 1
async def test_long_press(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.long_press(w // 2, h // 2, duration_ms=800)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert _has_action(te, "ACTION_DOWN")
assert _has_action(te, "ACTION_UP")
assert _max_pointer_count(te) == 1
async def test_double_tap(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.double_tap(w // 2, h // 2, delay=0.12)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
downs = [e for e in te if e.get("action") == "ACTION_DOWN"]
ups = [e for e in te if e.get("action") == "ACTION_UP"]
assert len(downs) >= 2, f"Expected 2 ACTION_DOWNs, got {len(downs)}"
assert len(ups) >= 2, f"Expected 2 ACTION_UPs, got {len(ups)}"
assert _max_pointer_count(te) == 1
async def test_swipe(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.swipe(w // 2, h * 3 // 4, w // 2, h // 4, duration_ms=300)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert _has_action(te, "ACTION_DOWN")
assert _has_action(te, "ACTION_MOVE"), "Swipe produced no MOVE events"
assert _has_action(te, "ACTION_UP")
assert _max_pointer_count(te) == 1
async def test_fling(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.fling(w // 2, h * 3 // 4, w // 2, h // 4)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert _has_action(te, "ACTION_DOWN")
assert _has_action(te, "ACTION_UP")
assert _max_pointer_count(te) == 1
async def test_scroll_up(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.scroll_up(w // 2, h // 2, distance=400, duration_ms=300)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert _has_action(te, "ACTION_DOWN") and _has_action(te, "ACTION_UP")
async def test_scroll_down(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.scroll_down(w // 2, h // 2, distance=400, duration_ms=300)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert _has_action(te, "ACTION_DOWN") and _has_action(te, "ACTION_UP")
async def test_scroll_left(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.scroll_left(w // 2, h // 2, distance=300, duration_ms=300)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert _has_action(te, "ACTION_DOWN") and _has_action(te, "ACTION_UP")
async def test_scroll_right(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.scroll_right(w // 2, h // 2, distance=300, duration_ms=300)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert _has_action(te, "ACTION_DOWN") and _has_action(te, "ACTION_UP")
# ── SDK pinch (MT Protocol B via sendevent) ────────────────────────────
async def test_pinch_in(self, sb: Sandbox):
"""sb.mobile.pinch_in delivers genuine pointer_count == 2 events."""
w, h = await sb.get_dimensions()
await sb.mobile.pinch_in(w // 2, h // 2, spread=200, duration_ms=400)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No events received"
assert (
_max_pointer_count(te) >= 2
), f"pinch_in: expected pointer_count >= 2, got {_max_pointer_count(te)}"
assert _has_action(
te, "ACTION_POINTER_DOWN"
), f"Missing ACTION_POINTER_DOWN — actions: {[e.get('action') for e in te]}"
async def test_pinch_out(self, sb: Sandbox):
"""sb.mobile.pinch_out delivers genuine pointer_count == 2 events."""
w, h = await sb.get_dimensions()
await sb.mobile.pinch_out(w // 2, h // 2, spread=200, duration_ms=400)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No events received"
assert (
_max_pointer_count(te) >= 2
), f"pinch_out: expected pointer_count >= 2, got {_max_pointer_count(te)}"
assert _has_action(te, "ACTION_POINTER_DOWN")
# ── SDK gesture() — arbitrary multi-finger paths ──────────────────────
async def test_gesture_pinch_out(self, sb: Sandbox):
"""sb.mobile.gesture() with two finger paths produces pointer_count == 2."""
w, h = await sb.get_dimensions()
cx, cy, spread = w // 2, h // 2, 200
await sb.mobile.gesture(
(cx - 20, cy),
(cx - spread, cy), # finger 0: start → end
(cx + 20, cy),
(cx + spread, cy), # finger 1: start → end
)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No events received from gesture()"
assert _max_pointer_count(te) >= 2
assert _has_action(te, "ACTION_POINTER_DOWN")
async def test_gesture_two_finger_swipe(self, sb: Sandbox):
"""Two parallel fingers moving down together."""
w, h = await sb.get_dimensions()
offset = 150
await sb.mobile.gesture(
(w // 2 - offset, h // 4),
(w // 2 - offset, h * 3 // 4),
(w // 2 + offset, h // 4),
(w // 2 + offset, h * 3 // 4),
)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No events received"
move_two = [
e for e in te if e.get("action") == "ACTION_MOVE" and e.get("pointer_count", 0) >= 2
]
assert move_two, "No ACTION_MOVE events with pointer_count >= 2"
# ── True multi-touch via MT Protocol B (server-side injection) ───────────
async def test_true_multitouch_pinch_in(self, sb: Sandbox):
"""MT Protocol B pinch-in via the multitouch_gesture server action."""
w, h = await sb.get_dimensions()
cx, cy, spread = w // 2, h // 2, 250
await sb.mobile.gesture(
(cx - spread, cy),
(cx - 30, cy),
(cx + spread, cy),
(cx + 30, cy),
)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No touch events"
assert _max_pointer_count(te) >= 2
assert _has_action(te, "ACTION_POINTER_DOWN")
async def test_true_multitouch_pinch_out(self, sb: Sandbox):
"""MT Protocol B pinch-out via the multitouch_gesture server action."""
w, h = await sb.get_dimensions()
cx, cy, spread = w // 2, h // 2, 250
await sb.mobile.gesture(
(cx - 30, cy),
(cx - spread, cy),
(cx + 30, cy),
(cx + spread, cy),
)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No touch events"
assert _max_pointer_count(te) >= 2
async def test_true_multitouch_two_finger_swipe(self, sb: Sandbox):
"""Two parallel fingers moving in the same direction."""
w, h = await sb.get_dimensions()
offset = 150
await sb.mobile.gesture(
(w // 2 - offset, h * 3 // 4),
(w // 2 - offset, h // 4),
(w // 2 + offset, h * 3 // 4),
(w // 2 + offset, h // 4),
duration_ms=300,
steps=15,
)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No touch events"
move_with_two = [
e for e in te if e.get("action") == "ACTION_MOVE" and e.get("pointer_count", 0) >= 2
]
assert move_with_two, "No ACTION_MOVE events with pointer_count >= 2"
async def test_pointer_ids_are_distinct(self, sb: Sandbox):
"""Each finger in a two-finger gesture must carry a unique pointer id."""
w, h = await sb.get_dimensions()
cx, cy = w // 2, h // 2
await sb.mobile.gesture(
(cx - 200, cy),
(cx - 50, cy),
(cx + 200, cy),
(cx + 50, cy),
steps=8,
)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
two_ptr = [e for e in te if e.get("pointer_count", 0) >= 2]
assert two_ptr, "No two-pointer events found"
for ev in two_ptr:
ids = {p["id"] for p in ev.get("pointers", [])}
assert len(ids) >= 2, f"Duplicate pointer ids in event: {ev}"
# ══════════════════════════════════════════════════════════════════════════════
# Local tests
# ══════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
class TestAndroidMultitouchLocal(_MultitouchTests):
"""Full touch action suite against a bare-metal AndroidEmulatorRuntime."""
@pytest_asyncio.fixture(loop_scope="session")
async def sb(self, local_android_sb: Sandbox):
await _reset(local_android_sb)
return local_android_sb
# ══════════════════════════════════════════════════════════════════════════════
# Cloud tests
# ══════════════════════════════════════════════════════════════════════════════
skip_no_api_key = pytest.mark.skipif(not _API_KEY, reason="CUA_TEST_API_KEY not set")
# Note: cloud Android tests are gated by API key only — hardware compat is the cloud's concern.
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def cloud_android_sb():
"""
Spin up an ephemeral cloud Android VM, install the TouchTest APK, escalate
to root, launch the app, and yield the ready Sandbox.
Shared across all cloud tests so we only pay the boot cost once.
"""
if not _API_KEY:
pytest.skip("CUA_TEST_API_KEY not set — cloud tests skipped")
image = Image.android("14").apk_install(_APK_RELEASE_URL)
async with Sandbox.ephemeral(image, api_key=_API_KEY) as sb:
# Launch app
await sb.shell.run(f"am start -n {_APK_ACTIVITY}")
await asyncio.sleep(_LAUNCH_WAIT_S)
yield sb
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.skipif(not _API_KEY, reason="CUA_TEST_API_KEY not set")
class TestAndroidMultitouchCloud(_MultitouchTests):
"""Full touch action suite against a cloud-hosted Android VM."""
@pytest_asyncio.fixture(loop_scope="session")
async def sb(self, cloud_android_sb: Sandbox):
await _reset(cloud_android_sb)
return cloud_android_sb