diff --git a/.env.example b/.env.example index 1426f73..5972674 100644 --- a/.env.example +++ b/.env.example @@ -2,49 +2,63 @@ # YOUR device's values. Both files are .gitignore'd by default. # # You only need to fill in the section for the platform you're using. +# Run `mobile-use init` for a guided fill — auto-detects connected devices. # ============================================================================ # iOS (iphone-harness) # ============================================================================ -# ---- Required ---- - -# Your iPhone's UDID. Find it with: -# idevice_id -l (libimobiledevice — `brew install libimobiledevice`) -# xcrun xctrace list devices (Apple's tool, also lists offline devices) +# required — iPhone UDID. Find with: `idevice_id -l` or `xcrun xctrace list devices` IPH_UDID=YOUR-IPHONE-UDID-HERE -# Your Apple Developer Team ID (10 chars). Find at developer.apple.com → Membership. +# required — Apple Developer Team ID (10 chars). +# Find at developer.apple.com → Membership, or Keychain Access → your Apple Development cert → Get Info → Organizational Unit. IPH_XCODE_ORG_ID=YOURTEAMID -# A bundle id YOU own/can sign, used for WebDriverAgent on the device. -# Example: com..mobile-use.wda +# required — A reverse-DNS bundle id you can sign. Example: com..mobile-use.wda +# This becomes the bundle ID of WebDriverAgent on your device. IPH_WDA_BUNDLE_ID=com.example.mobile-use.wda -# ---- Optional (iOS) ---- - +# optional — pinned iOS version (Appium usually auto-detects) # IPH_PLATFORM_VERSION=18.3.2 + +# optional — device display name (visible in Appium logs) # IPH_DEVICE_NAME=My iPhone + +# optional — non-default Appium server URL # IPH_APPIUM_URL=http://127.0.0.1:4723 + +# optional — daemon instance name (for multibox / multiple devices) # IPH_NAME=default + +# optional — enable domain-specific skills (per-app playbooks) # IPH_DOMAIN_SKILLS=1 + +# optional — seconds Appium waits between agent calls before killing session # IPH_NEW_COMMAND_TIMEOUT=600 + # ============================================================================ # Android (android-harness) # ============================================================================ -# ---- Required ---- - -# Your Android device serial. Find it with: -# adb devices +# required — Android device serial. Find with: `adb devices` ANH_UDID=YOUR-ANDROID-SERIAL-HERE -# ---- Optional (Android) ---- - +# optional — pinned Android version # ANH_PLATFORM_VERSION=14.0 + +# optional — device display name # ANH_DEVICE_NAME=Android + +# optional — non-default Appium server URL # ANH_APPIUM_URL=http://127.0.0.1:4723 + +# optional — daemon instance name (for multibox) # ANH_NAME=default + +# optional — enable domain-specific skills # ANH_DOMAIN_SKILLS=1 + +# optional — seconds Appium waits between agent calls before killing session # ANH_NEW_COMMAND_TIMEOUT=600 diff --git a/README.md b/README.md index cfdc419..04d2a2a 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,38 @@ If anything fails: ```bash mobile-use --doctor # numbered checks with one-line remediations iphone-harness --reload # nuke the daemon (rare but kills weird stale state) +mobile-use ios sign-wda # iOS: re-sign WebDriverAgent (the #1 setup blocker) +mobile-use ios build-wda # iOS: build the WDA test target (first-run setup) +mobile-use quickstart --autostart-appium # spawn Appium server in background ``` -See [`SETUP.md`](SETUP.md) for the manual / per-step appendix, or skip to the -sections below for usage. +See [`SETUP.md`](SETUP.md) for the manual / per-step appendix, including a +[troubleshooting decision tree](SETUP.md#part-c--troubleshooting). Linux users: +the Android path works out-of-the-box on apt/dnf/pacman systems (iOS still needs +macOS + Xcode). + +### Runtime helpers (no device pain) + +```python +from iphone_harness.helpers import wake_device, retry_on_disconnect, record_screen + +wake_device() # screen-off / locked? wake it. + +@retry_on_disconnect(max_attempts=3) # USB blip / WDA crash → auto-restart + retry +def run_script(): + tap(find(label="Compose")) + type_text("hello") + +record_screen(duration=10) # save mp4 to /tmp (XCUITest + UIAutomator2) + +# record/replay a tap sequence: +from mobile_use import record_replay +import iphone_harness.helpers as h +record_replay.start_recording("flow.py", helpers=h) +# ... your taps/swipes/typing ... +record_replay.stop_recording() # writes runnable flow.py +record_replay.replay("flow.py") # play it back +``` ### Manual setup (skip if `mobile-use bootstrap` worked) @@ -169,6 +197,53 @@ pool.broadcast_android(lambda d: d.press_home()) Each device gets its own named daemon instance (`IPH_NAME` / `ANH_NAME`) with separate sockets, so they don't collide. +## Headed mode — watch the device while it runs + +By default mobile-use is **headless**: scripts run, the daemon talks to the +device, you see no UI. Add `--headed` to spin up a local MJPEG viewer in +your browser and watch the live device screen mirror while the script runs: + +```bash +mobile-use --ios --headed -c 'tap_at_xy(100, 200); time.sleep(2)' +# → opens http://127.0.0.1:/ in your default browser +# → live mirror at ~6 fps, JPEG quality 60 (knobs in mobile_use/viewer/server.py) +``` + +The viewer is read-only — it shows what the device is doing; it doesn't +take input. Use `--headless` (or omit the flag) to skip it. Works on iOS +and Android. + +Quality knobs (via Python API, when running in agent mode): + +```python +from mobile_use.viewer.server import ViewerServer +v = ViewerServer(platform="ios", fps=12, quality=80, max_dim=1200) +v.start(); print(v.url) +# ... +v.stop() +``` + +## iOS from Windows / Linux + +Windows hosts can't build WebDriverAgent (no Xcode). Drive iOS via a Mac +on the network running the daemon over TCP: + +```bash +# On the Mac (one time): full Part A in SETUP.md +# On the Mac (each session): +IPH_BIND=tcp://127.0.0.1:8763 iphone-harness -c 'pass' + +# On Windows / Linux: +ssh -L 8763:127.0.0.1:8763 user@mac.local # SSH tunnel (recommended) +mobile-use --ios --remote-daemon tcp://127.0.0.1:8763 -c 'print(active_app())' + +# Add --headed to also see the live screen mirror in your local browser: +mobile-use --ios --remote-daemon tcp://127.0.0.1:8763 --headed -c '...' +``` + +Full walkthrough + security caveat: SETUP.md → "iOS from Windows / Linux +(remote Mac bridge)". + ## Skills ### iOS Interaction Skills @@ -339,6 +414,13 @@ paste_text(text, ...) # Device unlock() +# Navigation (both platforms — Android native buttons, iOS gesture equivalents) +press_home() # both — go to home screen +press_back() # Android: back key; iOS: swipe-from-left edge +press_recents() # Android: recents; iOS: app switcher +swipe_back() # iOS: explicit edge-swipe (alias for press_back on iOS) +open_app_switcher() # iOS: swipe up + pause + # iOS-only native_screenshot() # saves to iPhone Photos set_assistive_touch(on=True) @@ -349,9 +431,6 @@ start_screen_recording() stop_screen_recording() # Android-only -press_back() -press_home() -press_recents() open_notifications() close_notifications() grant_permission(package, permission) diff --git a/SETUP.md b/SETUP.md index 899833d..948fbc5 100644 --- a/SETUP.md +++ b/SETUP.md @@ -112,11 +112,127 @@ iphone-harness -c 'print(active_app())' --- +# Part A* — iOS from Windows / Linux (remote Mac bridge) + +Windows and Linux cannot build or sign WebDriverAgent locally — `xcodebuild` +and Apple codesigning are macOS-only. The path is to keep one Mac on the +network as the "iOS bridge" and drive it remotely via TCP. The mobile-use +client running on Windows/Linux talks to the daemon on the Mac; the Mac +talks to the iPhone via Appium + WDA exactly as in Part A. + +### One-time setup on the Mac + +Follow Part A above on the Mac (Xcode, libimobiledevice, Appium, WDA signing, +.env with IPH_UDID/IPH_XCODE_ORG_ID/IPH_WDA_BUNDLE_ID). + +Verify it works on the Mac itself before adding network in the mix: + +```bash +iphone-harness -c 'print(active_app())' +``` + +### Each session on the Mac + +Start the daemon bound to TCP loopback (preferred — pair with SSH tunnel) OR +to all interfaces (faster setup, less secure — see security caveat below). + +Loopback + SSH tunnel (recommended): + +```bash +# On the Mac: +IPH_BIND=tcp://127.0.0.1:8763 iphone-harness -c 'pass' # starts daemon, exits client +# Daemon keeps running. Re-running 'iphone-harness -c' attaches to it. +``` + +All-interfaces (skip SSH, faster — security warning printed to stderr): + +```bash +IPH_BIND=tcp://0.0.0.0:8763 iphone-harness -c 'pass' +``` + +### On Windows or Linux + +```bash +pip install mobile-use # Android-only deps; iOS daemon never runs locally + +# Open SSH tunnel in another terminal (skip if Mac is bound to 0.0.0.0): +ssh -L 8763:127.0.0.1:8763 user@mac.local + +# Drive iOS: +mobile-use --ios --remote-daemon tcp://127.0.0.1:8763 -c 'print(active_app())' + +# Or with the headed viewer in the browser: +mobile-use --ios --remote-daemon tcp://127.0.0.1:8763 --headed -c 'print(active_app())' +``` + +### How it works + +- `IPH_BIND=tcp://...` on the Mac switches the daemon's IPC from AF_UNIX + to TCP. AF_UNIX is unchanged when IPH_BIND is unset. +- `--remote-daemon tcp://...` on the client sets `IPH_CONNECT` and flips + the harness into **client-only mode**: `ensure_daemon` never tries to + spawn a local daemon; it pings the remote, raises a remediation + checklist if unreachable. +- The daemon over TCP serves the same JSON-line RPC protocol as over + AF_UNIX — no new methods, no protocol break. Everything that works + locally works remotely. + +### Security caveat + +The RPC is **unauthenticated**. Anything that can connect to the daemon's +port can drive the phone. Mitigations: + +- Bind 127.0.0.1 on the Mac and use SSH tunnels (encrypts + authenticates + via SSH). This is the recommended pattern. +- If you must bind 0.0.0.0, put a firewall rule (`pf` on macOS) in front + that only allows your specific Windows/Linux IP. +- Future: HMAC token in `IPH_CONNECT_TOKEN` env (tracked as a follow-up). + +### Troubleshooting from the client + +``` +iphone-harness: remote daemon unreachable at tcp://127.0.0.1:8763 +``` + +Means: client can't reach the daemon. On the Mac, check: + +```bash +pgrep -fa iphone_harness.daemon # daemon process alive? +lsof -iTCP -sTCP:LISTEN | grep python # bound to TCP? +``` + +On Windows: `Test-NetConnection -ComputerName -Port 8763`. + +--- + # Part B — Android Setup Android setup is significantly simpler than iOS — no signing, no Xcode, no provisioning. +**Works on macOS and Linux.** iOS requires macOS+Xcode; Android does not. + +## B1. System tools + +### Linux (Ubuntu/Debian/Fedora/Arch) + +```bash +# Ubuntu / Debian / Mint: +sudo apt install -y android-tools-adb nodejs npm + +# Fedora / RHEL / Rocky: +sudo dnf install -y android-tools nodejs npm + +# Arch / Manjaro: +sudo pacman -S --noconfirm android-tools nodejs npm + +# Then (any Linux): +npm i -g appium +appium driver install uiautomator2 +pip install -e . +``` + +`mobile-use bootstrap` autodetects the Linux package manager via `/etc/os-release`. -## B1. System tools (Mac side) +### macOS ### Install Android SDK Platform Tools @@ -222,6 +338,61 @@ for el in ui_tree(visible_only=True)[:5]: # Part C — Troubleshooting +## Decision tree — most common failures + +``` +Something broke. +│ +├── First: run `mobile-use --doctor` — shows the bad check + a one-line fix. +│ +├── "daemon unreachable" / "stale session" +│ └── → `iphone-harness --reload` (or `android-harness --reload`). +│ Restart Appium too if the issue persists. +│ +├── iOS: "xcodebuild failed with code 65" +│ └── → WDA signing. Run `mobile-use ios sign-wda`. +│ Free Apple account? Re-sign weekly (profile expires every 7 days). +│ +├── iOS: "Tunnel registry port not found" +│ └── → xcuitest 11.x bug. Pin to 10.43.1: +│ `appium driver install --source=npm appium-xcuitest-driver@10.43.1` +│ +├── Android: "device not found" / "unauthorized" +│ └── → unplug, replug, tap **Allow** on the phone. +│ Verify with `adb devices`. +│ +├── "USB disconnect during script" +│ └── → Wrap your script with `@retry_on_disconnect(max_attempts=3)`. +│ Plug into a powered USB hub instead of a laptop port if it keeps happening. +│ +├── "device locked" / "screen off" +│ └── → Call `wake_device()` at the top of your script. +│ +├── Tests pass but real run fails +│ └── → Daemon is stuck. `rm /tmp/iph-*.sock /tmp/iph-*.pid` (iOS) or +│ `rm /tmp/anh-*.sock /tmp/anh-*.pid` (Android), then re-run. +│ +└── Everything else + └── → `mobile-use --doctor` then read SETUP.md for that step. +``` + +## Daemon logs (debugging) + +When `iphone-harness -c '...'` or `android-harness -c '...'` fails after running for a while, check the daemon logs: + +```bash +# iOS (one log per IPH_NAME, default "default"): +tail -50 /tmp/iph-default.log + +# Android: +tail -50 /tmp/anh-default.log + +# Live tail while running another shell: +tail -f /tmp/iph-default.log +``` + +The daemon writes `connecting to Appium…`, `session ok`, `stale session, reconnecting`, and any `fatal:` lines here. If the log is empty or missing, the daemon never started — run `mobile-use --doctor`. + ## iOS - **`xcodebuild failed with code 65`**: check Appium server log — usually untrusted cert, missing provisioning, or missing device support files. diff --git a/android_harness/_ipc.py b/android_harness/_ipc.py index e84746f..0d93b03 100644 --- a/android_harness/_ipc.py +++ b/android_harness/_ipc.py @@ -1,13 +1,18 @@ -"""Daemon IPC plumbing. AF_UNIX socket on POSIX. +"""Daemon IPC plumbing — AF_UNIX (default) or TCP. -Same protocol as iphone_harness/_ipc.py — AF_UNIX JSON-line RPC, one daemon -per ANH_NAME. Separate socket namespace so iOS and Android daemons can coexist. +Same protocol as iphone_harness/_ipc.py — JSON-line RPC, one daemon per +ANH_NAME. Separate socket namespace so iOS and Android daemons can coexist. + +TCP mode (via ANH_BIND server-side / ANH_CONNECT client-side env vars) lets +a viewer process or remote operator drive Android over the network. See +iphone_harness/_ipc.py for the endpoint URI grammar. """ import asyncio import json import os import re import socket +import sys from pathlib import Path ANH_TMP_DIR = os.environ.get("ANH_TMP_DIR") @@ -17,6 +22,7 @@ _TMP.mkdir(parents=True, exist_ok=True) _RUNTIME.mkdir(parents=True, exist_ok=True) _NAME_RE = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z") +_LOOPBACK = {"127.0.0.1", "::1", "localhost"} def _check(name): @@ -40,8 +46,58 @@ def pid_path(name): return _RUNTIME / f"{_runtime_stem(name)}.pid" def _sock_path(name): return _RUNTIME / f"{_runtime_stem(name)}.sock" +def parse_endpoint(spec): + """Parse 'unix:/path' or 'tcp://host:port' → ('unix', path) | ('tcp', host, port).""" + if not isinstance(spec, str) or not spec: + raise ValueError("empty endpoint spec") + if spec.startswith("unix:"): + path = spec[len("unix:"):] + if path.startswith("//"): + path = path[2:] + if not path: + raise ValueError(f"unix endpoint missing path: {spec!r}") + return ("unix", path) + if spec.startswith("tcp://"): + rest = spec[len("tcp://"):] + if ":" not in rest: + raise ValueError(f"tcp endpoint missing port: {spec!r}") + host, _, port_s = rest.rpartition(":") + if host.startswith("[") and host.endswith("]"): + host = host[1:-1] + try: + port = int(port_s) + except ValueError: + raise ValueError(f"tcp endpoint port not integer: {spec!r}") + if not (0 < port < 65536): + raise ValueError(f"tcp endpoint port out of range: {port}") + if not host: + raise ValueError(f"tcp endpoint missing host: {spec!r}") + return ("tcp", host, port) + raise ValueError(f"endpoint must start with 'unix:' or 'tcp://': {spec!r}") + + +def bind_endpoint(name): + """Server-side endpoint. ANH_BIND overrides; default = unix path under runtime dir.""" + spec = os.environ.get("ANH_BIND") + if spec: + return parse_endpoint(spec) + return ("unix", str(_sock_path(name))) + + +def connect_endpoint(name): + """Client-side endpoint. ANH_CONNECT overrides; default = unix path under runtime dir.""" + spec = os.environ.get("ANH_CONNECT") + if spec: + return parse_endpoint(spec) + return ("unix", str(_sock_path(name))) + + def sock_addr(name): - return str(_sock_path(name)) + """Human-readable endpoint string for logs/error messages. Honors ANH_BIND.""" + ep = bind_endpoint(name) + if ep[0] == "unix": + return ep[1] + return f"tcp://{ep[1]}:{ep[2]}" def spawn_kwargs(): @@ -49,13 +105,25 @@ def spawn_kwargs(): def connect(name, timeout=1.0): - s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - s.settimeout(timeout) - s.connect(str(_sock_path(name))) + """Blocking client. Returns (sock, token); token is always None. + + Endpoint comes from ANH_CONNECT (or default AF_UNIX path). + """ + ep = connect_endpoint(name) + if ep[0] == "unix": + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect(ep[1]) + return s, None + _, host, port = ep + s = socket.create_connection((host, port), timeout=timeout) return s, None +_MAX_MSG = 64 * 1024 * 1024 # 64 MB cap + def request(c, token, req): + """Caps incoming data at _MAX_MSG to prevent unbounded memory growth.""" if token: req = {**req, "token": token} c.sendall((json.dumps(req) + "\n").encode()) @@ -65,6 +133,10 @@ def request(c, token, req): if not chunk: break data += chunk + if len(data) > _MAX_MSG: + raise RuntimeError( + f"IPC response exceeded {_MAX_MSG // (1024*1024)}MB cap — daemon malfunction?" + ) return json.loads(data or b"{}") @@ -102,14 +174,27 @@ def identify(name, timeout=1.0): async def serve(name, handler): - path = str(_sock_path(name)) - if os.path.exists(path): - os.unlink(path) - old_umask = os.umask(0o077) - try: - server = await asyncio.start_unix_server(handler, path=path) - finally: - os.umask(old_umask) + """Run the server until cancelled. Endpoint comes from ANH_BIND (or default AF_UNIX).""" + ep = bind_endpoint(name) + if ep[0] == "unix": + path = ep[1] + if os.path.exists(path): + os.unlink(path) + old_umask = os.umask(0o077) + try: + server = await asyncio.start_unix_server(handler, path=path) + finally: + os.umask(old_umask) + else: + _, host, port = ep + if host not in _LOOPBACK: + print( + f"android-harness: WARNING — TCP daemon binding to {host}:{port} " + f"(non-loopback). RPC is unauthenticated; use an SSH tunnel " + f"(ssh -L {port}:127.0.0.1:{port} ) or restrict at firewall.", + file=sys.stderr, + ) + server = await asyncio.start_server(handler, host=host, port=port) async with server: await asyncio.Event().wait() @@ -119,6 +204,11 @@ def expected_token(): def cleanup_endpoint(name): - p = _sock_path(name) - try: p.unlink() - except FileNotFoundError: pass + """Remove the unix socket file. No-op for TCP.""" + ep = bind_endpoint(name) + if ep[0] != "unix": + return + try: + Path(ep[1]).unlink() + except FileNotFoundError: + pass diff --git a/android_harness/admin.py b/android_harness/admin.py index 207b91a..2a6dcf7 100644 --- a/android_harness/admin.py +++ b/android_harness/admin.py @@ -7,12 +7,34 @@ import urllib.error import urllib.request +from mobile_use._platform import ( + LINUX_ADB_PKGS, + LINUX_NODE_PKGS, + install_hint, + is_linux, + is_macos, +) + from . import _ipc as ipc NAME = os.environ.get("ANH_NAME", "default") APPIUM_URL = os.environ.get("ANH_APPIUM_URL", "http://127.0.0.1:4723") +def is_remote_daemon(name=None): + """True when ANH_CONNECT points at a TCP daemon we don't manage locally. + Same client-only semantics as iphone_harness — ensure_daemon won't spawn. + """ + spec = os.environ.get("ANH_CONNECT") + if not spec: + return False + try: + kind, *_ = ipc.parse_endpoint(spec) + except ValueError: + return False + return kind == "tcp" + + def _version(): try: from importlib.metadata import version @@ -25,7 +47,87 @@ def daemon_alive(name=None): return ipc.ping(name or NAME, timeout=1.0) +def _pid_alive(pid): + """True if a process with this pid exists.""" + # isinstance(True, int) is True — reject bool to avoid os.kill(1, 0). + if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0: + return False + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return False + + +def cleanup_stale(name=None): + """Remove leftover .pid + (for AF_UNIX) .sock from a dead daemon. + TCP endpoints have no socket file — only pidfile gets cleaned.""" + name = name or NAME + pid_path = ipc.pid_path(name) + endpoint = ipc.bind_endpoint(name) + + if ipc.ping(name, timeout=0.3): + return False + + cleaned = False + try: + recorded = int(pid_path.read_text().strip()) + except (FileNotFoundError, ValueError, UnicodeDecodeError, PermissionError, OSError): + recorded = None + + if recorded is not None and not _pid_alive(recorded): + try: + pid_path.unlink() + cleaned = True + except FileNotFoundError: + pass + elif recorded is None and pid_path.exists(): + try: + pid_path.unlink() + cleaned = True + except FileNotFoundError: + pass + + if endpoint[0] == "unix": + from pathlib import Path as _P + sock_path = _P(endpoint[1]) + if sock_path.exists(): + try: + sock_path.unlink() + cleaned = True + except FileNotFoundError: + pass + + return cleaned + + def ensure_daemon(wait=30.0, name=None, env=None): + """Idempotent. In client-only mode (ANH_CONNECT=tcp://:port), + NEVER spawns locally — only pings + raises remediation on failure.""" + name_eff = name or NAME + + if is_remote_daemon(name_eff): + spec = os.environ.get("ANH_CONNECT", "") + if daemon_alive(name_eff): + return + raise RuntimeError( + f"android-harness: remote daemon unreachable at {spec}.\n" + f" This host is in client-only mode (ANH_CONNECT set).\n" + f" Checks on the remote host:\n" + f" - daemon running? (ssh host 'pgrep -fa android_harness.daemon')\n" + f" - bound to TCP? (ssh host 'lsof -iTCP -sTCP:LISTEN | grep python')\n" + f" Prefer `ssh -L :127.0.0.1: ` over exposing\n" + f" the daemon on 0.0.0.0 — the RPC is unauthenticated." + ) + + _ensure_daemon_local(wait=wait, name=name, env=env) + + +def _ensure_daemon_local(wait=30.0, name=None, env=None): name = name or NAME if daemon_alive(name): try: @@ -40,9 +142,13 @@ def ensure_daemon(wait=30.0, name=None, env=None): pass restart_daemon(name) + cleanup_stale(name) + e = {**os.environ, **({"ANH_NAME": name} if name else {}), **(env or {})} + # ANH_DAEMON_MODULE is a test-only escape hatch; defaults to real daemon. + module = e.get("ANH_DAEMON_MODULE", "android_harness.daemon") p = subprocess.Popen( - [sys.executable, "-m", "android_harness.daemon"], + [sys.executable, "-m", module], env=e, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **ipc.spawn_kwargs(), ) @@ -94,7 +200,16 @@ def restart_daemon(name=None): os.kill(daemon_pid, signal.SIGTERM) except (ProcessLookupError, PermissionError): pass - time.sleep(0.5) + deadline = time.time() + 2.0 + while time.time() < deadline and _pid_alive(daemon_pid): + time.sleep(0.1) + + if daemon_pid and _pid_alive(daemon_pid): + try: + os.kill(daemon_pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + time.sleep(0.2) try: os.unlink(pid_path) except FileNotFoundError: pass @@ -125,7 +240,8 @@ def _check_device(): return True, f"connected ({udid})" return False, f"serial {udid} not in `adb devices`: {lines!r}" except FileNotFoundError: - return False, "`adb` not installed (brew install android-platform-tools)" + hint = install_hint("android-platform-tools", LINUX_ADB_PKGS) + return False, f"`adb` not installed ({hint})" except Exception as e: return False, str(e) @@ -139,8 +255,27 @@ def _check_device(): ANDROID_REQUIRED_ENV = ("ANH_UDID",) +def _check_adb(): + """Return (True, path) if adb is on PATH. Distro-neutral check. + + On macOS, this catches both Homebrew (`brew install android-platform-tools`) + and Android Studio installs. On Linux, this catches every distro that has + an `android-tools`/`android-tools-adb` package. + """ + p = shutil.which("adb") + if p is None: + return False, "adb not on PATH" + try: + v = subprocess.check_output([p, "version"], timeout=3.0, + stderr=subprocess.STDOUT).decode().strip().splitlines()[0] + return True, v + except Exception: + return True, p # binary exists; version probe is bonus + + def _check_brew_pkg(pkg): - if sys.platform != "darwin": + """Legacy shim — kept so external callers + tests still import this.""" + if not is_macos(): return True, "(skipped — non-macOS)" brew = shutil.which("brew") if brew is None: @@ -238,15 +373,64 @@ def _check_python_pkg(): return False, str(e) +def _check_battery(): + """Return (True, level%) if Android battery > 20%.""" + udid = os.environ.get("ANH_UDID") + if shutil.which("adb") is None: + return True, "(skipped — adb not on PATH)" + cmd = ["adb"] + if udid: + cmd += ["-s", udid] + cmd += ["shell", "dumpsys", "battery"] + try: + out = subprocess.check_output(cmd, timeout=5.0, stderr=subprocess.DEVNULL).decode() + # `level: 73` line + for line in out.splitlines(): + line = line.strip() + if line.startswith("level:"): + raw = line.split(":", 1)[1].strip() + try: + level = int(raw) + except (ValueError, TypeError): + return True, f"(skipped — battery level unreadable: {raw!r})" + if level < 20: + return True, f"{level}% (WARN: low — plug in to avoid disconnect)" + return True, f"{level}%" + return True, "(skipped — level field missing)" + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError, FileNotFoundError): + return True, "(skipped — battery info unavailable)" + + +def _check_screen_unlocked(): + """Return (True, state) if Android device screen is unlocked. (Doesn't fail if locked, + just informs the user.)""" + if shutil.which("adb") is None: + return True, "(skipped — adb not on PATH)" + udid = os.environ.get("ANH_UDID") + cmd = ["adb"] + if udid: + cmd += ["-s", udid] + cmd += ["shell", "dumpsys", "power"] + try: + out = subprocess.check_output(cmd, timeout=5.0, stderr=subprocess.DEVNULL).decode() + if "mWakefulness=Awake" in out: + return True, "screen on, awake" + return True, "screen off (helpers will wake_device() before interacting)" + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + return True, "(skipped — power info unavailable)" + + def run_doctor(): print(f"android-harness {_version() or '(dev)'}\n") rc = 0 + adb_hint = install_hint("android-platform-tools", LINUX_ADB_PKGS) + node_hint = install_hint("node", LINUX_NODE_PKGS) checks = [ - ("Homebrew package: android-platform-tools (adb)", _check_brew_pkg, ("android-platform-tools",), - "brew install android-platform-tools"), + ("adb (Android Platform Tools)", _check_adb, (), + adb_hint), ("Node.js + npm", _check_node, (), - "brew install node"), + node_hint), ("Appium installed", _check_appium_installed, (), "npm i -g appium"), ("Appium uiautomator2 driver", _check_driver_installed, ("uiautomator2",), @@ -261,6 +445,10 @@ def run_doctor(): f"Start Appium with `appium --base-path /` (port 4723; URL: {APPIUM_URL})"), ("Android device connected + USB debugging authorized", _check_device, (), "Plug in Android, Settings → Developer options → USB debugging → On, tap Allow on prompt"), + ("Device battery level (>20% recommended)", _check_battery, (), + "Plug in the device to charge. Low battery causes USB disconnects."), + ("Screen wakefulness", _check_screen_unlocked, (), + "Press power button. Or use wake_device() helper before interacting."), ] total = len(checks) + 2 diff --git a/android_harness/daemon.py b/android_harness/daemon.py index 0f64b20..d080a1d 100644 --- a/android_harness/daemon.py +++ b/android_harness/daemon.py @@ -92,6 +92,13 @@ def __init__(self): self.driver = None self.stop = None self._loop = None + # Screen-stream state — populated by screen_stream_start RPC. + self._stream_task = None + self._stream_frame = None + self._stream_frame_no = 0 + self._stream_fps = 6.0 + self._stream_quality = 60 + self._stream_max_dim = 800 async def _drive(self, fn): return await self._loop.run_in_executor(None, fn) @@ -177,6 +184,79 @@ async def _m_screenshot(d, params): return {"path": path, "bytes": len(png)} +# ---- live screen stream (powers --headed viewer) -------------------------- + +async def _stream_loop(d): + """Capture frames at d._stream_fps; JPEG-encode; store latest.""" + import io + try: + from PIL import Image + except ImportError: + log("stream: Pillow not installed — install via `pip install pillow`") + return + while True: + period = 1.0 / max(0.1, d._stream_fps) + try: + png = await d._drive(d.driver.get_screenshot_as_png) + img = Image.open(io.BytesIO(png)) + if d._stream_max_dim and max(img.size) > d._stream_max_dim: + img.thumbnail((d._stream_max_dim, d._stream_max_dim)) + buf = io.BytesIO() + img.convert("RGB").save(buf, format="JPEG", quality=d._stream_quality) + d._stream_frame = buf.getvalue() + d._stream_frame_no += 1 + except asyncio.CancelledError: + raise + except Exception as e: + log(f"stream: capture failed: {e}") + await asyncio.sleep(period) + + +async def _m_screen_stream_start(d, params): + """Start (or reconfigure) capture. params: {fps, quality, max_dim}.""" + fps = float(params.get("fps", 6)) + quality = max(1, min(95, int(params.get("quality", 60)))) + max_dim = int(params.get("max_dim", 800)) + d._stream_fps = fps + d._stream_quality = quality + d._stream_max_dim = max_dim + if d._stream_task is not None and not d._stream_task.done(): + return {"running": True, "updated": True, "fps": fps, + "quality": quality, "max_dim": max_dim} + d._stream_frame_no = 0 + d._stream_task = asyncio.create_task(_stream_loop(d)) + return {"running": True, "started": True, "fps": fps, + "quality": quality, "max_dim": max_dim} + + +async def _m_screen_stream_frame(d, params): + """Return latest captured frame as base64 JPEG. Single-consumer pull model.""" + import base64 + if d._stream_frame is None: + return {"ready": False, "frame_no": 0} + return { + "ready": True, + "frame_no": d._stream_frame_no, + "jpeg_b64": base64.b64encode(d._stream_frame).decode("ascii"), + "fps": d._stream_fps, + "quality": d._stream_quality, + } + + +async def _m_screen_stream_stop(d, params): + """Cancel capture loop and clear buffered frame. Idempotent.""" + if d._stream_task is None: + return {"running": False} + d._stream_task.cancel() + try: + await d._stream_task + except (asyncio.CancelledError, Exception): + pass + d._stream_task = None + d._stream_frame = None + return {"running": False, "stopped": True} + + async def _m_page_source(d, params): return await d._drive(lambda: d.driver.page_source) @@ -280,6 +360,10 @@ def _do(): "send_keys": _m_send_keys, "set_value": _m_set_value, "active_app": _m_active_app, + # Live screen mirror — powers `mobile-use --headed`. + "screen_stream_start": _m_screen_stream_start, + "screen_stream_frame": _m_screen_stream_frame, + "screen_stream_stop": _m_screen_stream_stop, } diff --git a/android_harness/helpers.py b/android_harness/helpers.py index 21f6abc..34159ae 100644 --- a/android_harness/helpers.py +++ b/android_harness/helpers.py @@ -122,6 +122,103 @@ def appium(script, **args): return _send({"method": "appium", "params": {"script": script, "args": args}})["result"] +# ---- device state + recovery ----------------------------------------------- + +class DeviceDisconnectError(RuntimeError): + """Device became unreachable mid-call (USB disconnect, Appium timeout, ADB crash).""" + + +def is_locked(): + """True if screen is locked (lockscreen visible or display off).""" + try: + return bool(appium("mobile: isLocked")) + except Exception: + try: + info = appium("mobile: deviceInfo") + return bool(info.get("locked", False)) if isinstance(info, dict) else False + except Exception: + return False + + +def wake_device(): + """Wake screen + dismiss lock screen. Returns True iff device ends up unlocked. + + On Android, uses `mobile: unlock` if available, falls back to pressing + power + dismissing the lock screen via swipe up. + """ + if not is_locked(): + return True # already awake + unlocked = False + try: + appium("mobile: unlock") + unlocked = True + except Exception: + try: + appium("mobile: pressKey", keycode=26) # POWER + time.sleep(0.5) + appium("mobile: pressKey", keycode=82) # MENU (some devices unlock) + unlocked = True + except Exception: + unlocked = False + if not unlocked: + return False + try: + return not is_locked() + except Exception: + return False + + +def retry_on_disconnect(max_attempts=3, backoff=0.5): + """Decorator: retry on device-disconnect errors with backoff + wake. + + Catches RuntimeError messages matching common disconnect signals + (USB pull, ADB session drop, UIAutomator2 timeout) and restarts the + daemon + wakes the device before each retry. + + Usage: + @retry_on_disconnect(max_attempts=3) + def open_app(package): + appium('mobile: activateApp', appId=package) + """ + if not isinstance(max_attempts, int) or max_attempts < 1: + raise ValueError(f"max_attempts must be >= 1, got {max_attempts!r}") + if not isinstance(backoff, (int, float)) or backoff < 0: + raise ValueError(f"backoff must be >= 0, got {backoff!r}") + DISCONNECT_PATTERNS = ( + "unreachable", "disconnect", "session", "stale", + "connection", "timed out", "adb", "uiautomator", + ) + + def decorator(fn): + def wrapper(*args, **kwargs): + last_err = None + for attempt in range(max_attempts): + try: + return fn(*args, **kwargs) + except RuntimeError as e: + msg = str(e).lower() + if not any(p.lower() in msg for p in DISCONNECT_PATTERNS): + raise + last_err = e + if attempt < max_attempts - 1: + time.sleep(backoff * (2 ** attempt)) + try: + from .admin import restart_daemon, ensure_daemon + restart_daemon() + ensure_daemon() + wake_device() + except Exception: + pass + raise DeviceDisconnectError( + f"{fn.__name__} failed after {max_attempts} attempts. Last error: {last_err}\n" + f"Run `android-harness --doctor` to diagnose." + ) from last_err + wrapper.__name__ = fn.__name__ + wrapper.__doc__ = fn.__doc__ + return wrapper + return decorator + + # ---- perception ------------------------------------------------------------ def screenshot(path=None): @@ -130,6 +227,33 @@ def screenshot(path=None): return r["path"] +# ---- live screen stream (consumed by viewer/server.py) -------------------- + +def screen_stream_start(fps=6, quality=60, max_dim=800): + """Start the daemon's screen-capture loop. Returns daemon's reply dict.""" + _drop_conn() + r = _send({ + "method": "screen_stream_start", + "params": {"fps": fps, "quality": quality, "max_dim": max_dim}, + }) + return r.get("result", {"running": False}) + + +def screen_stream_frame(): + """Pull the latest JPEG frame from the daemon. Drops cached socket first + since the daemon closes the conn per reply (silent empty otherwise).""" + _drop_conn() + r = _send({"method": "screen_stream_frame", "params": {}}, timeout=10.0) + return r.get("result", {"ready": False, "frame_no": 0}) + + +def screen_stream_stop(): + """Cancel the daemon's capture loop. Idempotent.""" + _drop_conn() + r = _send({"method": "screen_stream_stop", "params": {}}) + return r.get("result", {"running": False}) + + def window_size(): """Logical screen size: {'width': W, 'height': H}. These are the units tap_at_xy(x, y) expects. @@ -577,12 +701,20 @@ def ocr(image_path=None, languages=("en-US",)): """ if image_path is None: image_path = screenshot() + from mobile_use._platform import OCRNotAvailableError, is_macos + if not is_macos(): + raise OCRNotAvailableError( + "ocr() uses the macOS Vision framework — not bundled on this host. " + "Linux: install Tesseract (`sudo apt install tesseract-ocr` or " + "equivalent) and wrap it yourself, or run mobile_use from a macOS " + "host. See SETUP.md#ocr-on-linux for details." + ) try: import Vision from Foundation import NSURL except ImportError as e: - raise RuntimeError( - "ocr() needs PyObjC (macOS only). Install: pip install pyobjc-framework-Vision" + raise OCRNotAvailableError( + "ocr() needs PyObjC. Install: pip install pyobjc-framework-Vision" ) from e url = NSURL.fileURLWithPath_(image_path) @@ -695,6 +827,88 @@ def annotated_screenshot(path=None, run_ocr=True): return annotated, result +# ---- screen recording ------------------------------------------------------ + +import base64 as _base64 +import subprocess as _subprocess + +def record_screen(duration=10, path=None, bit_rate="4M", size=None): + """Record the device screen for `duration` seconds. Returns the host path. + + Uses Appium's `mobile: startRecordingScreen` (UIAutomator2 backend) which + wraps `adb shell screenrecord`. Returns base64-encoded MP4; we decode and + write locally. screenrecord caps each segment at 180s — for longer recordings + chain multiple calls. + + Args: + duration: seconds to record (max 180) + path: host filesystem path; defaults to /tmp/anh-record-.mp4 + bit_rate: e.g. '4M' (default), '8M' for higher quality + size: optional 'x' for downscaling + + Returns: + Path string of the saved .mp4 file. + """ + if not isinstance(duration, (int, float)) or duration <= 0: + raise ValueError(f"record_screen duration must be > 0, got {duration!r}") + if duration > 180: + raise RuntimeError( + f"adb screenrecord caps at 180s per segment; got {duration}.\n" + f" For longer: call record_screen() multiple times and concat with ffmpeg." + ) + if path is None: + path = str(ipc._TMP / f"anh-record-{int(time.time())}.mp4") + import os as _os + if _os.path.isdir(path): + raise IsADirectoryError(f"record_screen path is a directory: {path}") + parent = _os.path.dirname(_os.path.abspath(path)) + if parent and not _os.path.exists(parent): + _os.makedirs(parent, exist_ok=True) + args = {"timeLimit": int(duration) + 5, "bitRate": bit_rate} + if size: + args["videoSize"] = size + try: + appium("mobile: startRecordingScreen", **args) + except Exception as e: + raise RuntimeError( + f"start screen recording failed: {e}\n" + f" Likely cause: UIAutomator2 version doesn't support `mobile: startRecordingScreen`.\n" + f" Try updating: `appium driver install --source=npm appium-uiautomator2-driver@latest`." + ) + time.sleep(duration) + try: + b64 = appium("mobile: stopRecordingScreen") + except Exception as e: + raise RuntimeError(f"stop screen recording failed: {e}") + with open(path, "wb") as f: + f.write(_base64.b64decode(b64)) + return path + + +def start_screen_recording(bit_rate="4M", size=None): + """Start a non-blocking screen recording. Call stop_screen_recording() to finish. + + For one-shot recording with a known duration, prefer `record_screen()`. + """ + args = {"timeLimit": 600, "bitRate": bit_rate} + if size: + args["videoSize"] = size + appium("mobile: startRecordingScreen", **args) + + +def stop_screen_recording(path=None): + """Stop a recording started with start_screen_recording. Returns host path. + + The video is base64-encoded by Appium; we decode and save locally. + """ + if path is None: + path = str(ipc._TMP / f"anh-record-{int(time.time())}.mp4") + b64 = appium("mobile: stopRecordingScreen") + with open(path, "wb") as f: + f.write(_base64.b64decode(b64)) + return path + + # ---- agent-helpers hot-load ------------------------------------------------ _agent_helpers_loaded = False diff --git a/iphone_harness/_ipc.py b/iphone_harness/_ipc.py index 483a3c9..c5ca705 100644 --- a/iphone_harness/_ipc.py +++ b/iphone_harness/_ipc.py @@ -1,13 +1,22 @@ -"""Daemon IPC plumbing — AF_UNIX socket, part of mobile-use. - -Agent drives a physical iPhone from a Mac. AF_UNIX keeps the path short -and avoids TCP complexity. Linux works too for remote ssh-attached phones. +"""Daemon IPC plumbing — AF_UNIX (default) or TCP, part of mobile-use. + +Agent drives a physical iPhone from a Mac. AF_UNIX keeps the path short and +avoids TCP complexity for same-host operation. + +TCP mode (IPH_BIND for server-side, IPH_CONNECT for client-side) lets a +Windows or Linux host drive a remote Mac that's running Appium+WDA, and lets +a viewer process pull MJPEG frames over the network. Endpoint URIs: + unix:/tmp/iph-default.sock (default; identical to no env) + tcp://127.0.0.1:8763 (loopback — recommended; pair with `ssh -L`) + tcp://0.0.0.0:8763 (any iface — prints a security warning; + the RPC is unauthenticated) """ import asyncio import json import os import re import socket +import sys from pathlib import Path # AF_UNIX sun_path on macOS is 104 bytes. /tmp keeps the path short; macOS's @@ -19,6 +28,7 @@ _TMP.mkdir(parents=True, exist_ok=True) _RUNTIME.mkdir(parents=True, exist_ok=True) _NAME_RE = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z") +_LOOPBACK = {"127.0.0.1", "::1", "localhost"} def _check(name): @@ -42,8 +52,61 @@ def pid_path(name): return _RUNTIME / f"{_runtime_stem(name)}.pid" def _sock_path(name): return _RUNTIME / f"{_runtime_stem(name)}.sock" +def parse_endpoint(spec): + """Parse 'unix:/path' or 'tcp://host:port' → ('unix', path) | ('tcp', host, port). + + Raises ValueError on malformed input. + """ + if not isinstance(spec, str) or not spec: + raise ValueError("empty endpoint spec") + if spec.startswith("unix:"): + path = spec[len("unix:"):] + if path.startswith("//"): + path = path[2:] + if not path: + raise ValueError(f"unix endpoint missing path: {spec!r}") + return ("unix", path) + if spec.startswith("tcp://"): + rest = spec[len("tcp://"):] + if ":" not in rest: + raise ValueError(f"tcp endpoint missing port: {spec!r}") + host, _, port_s = rest.rpartition(":") + if host.startswith("[") and host.endswith("]"): + host = host[1:-1] + try: + port = int(port_s) + except ValueError: + raise ValueError(f"tcp endpoint port not integer: {spec!r}") + if not (0 < port < 65536): + raise ValueError(f"tcp endpoint port out of range: {port}") + if not host: + raise ValueError(f"tcp endpoint missing host: {spec!r}") + return ("tcp", host, port) + raise ValueError(f"endpoint must start with 'unix:' or 'tcp://': {spec!r}") + + +def bind_endpoint(name): + """Server-side endpoint. IPH_BIND overrides; default = unix path under runtime dir.""" + spec = os.environ.get("IPH_BIND") + if spec: + return parse_endpoint(spec) + return ("unix", str(_sock_path(name))) + + +def connect_endpoint(name): + """Client-side endpoint. IPH_CONNECT overrides; default = unix path under runtime dir.""" + spec = os.environ.get("IPH_CONNECT") + if spec: + return parse_endpoint(spec) + return ("unix", str(_sock_path(name))) + + def sock_addr(name): - return str(_sock_path(name)) + """Human-readable endpoint string for logs/error messages. Honors IPH_BIND.""" + ep = bind_endpoint(name) + if ep[0] == "unix": + return ep[1] + return f"tcp://{ep[1]}:{ep[2]}" def spawn_kwargs(): @@ -52,15 +115,29 @@ def spawn_kwargs(): def connect(name, timeout=1.0): - """Blocking client. Returns (sock, token); token is always None on POSIX.""" - s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - s.settimeout(timeout) - s.connect(str(_sock_path(name))) + """Blocking client. Returns (sock, token); token is always None. + + Endpoint comes from IPH_CONNECT (or default AF_UNIX path). + """ + ep = connect_endpoint(name) + if ep[0] == "unix": + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect(ep[1]) + return s, None + _, host, port = ep + s = socket.create_connection((host, port), timeout=timeout) return s, None +_MAX_MSG = 64 * 1024 * 1024 # 64 MB cap — covers any iPhone screenshot, screen video + def request(c, token, req): - """One-shot send + recv + parse on an open socket. Caller closes the socket.""" + """One-shot send + recv + parse on an open socket. Caller closes the socket. + + Caps incoming data at _MAX_MSG to prevent unbounded memory growth from a + malfunctioning or compromised daemon. + """ if token: req = {**req, "token": token} c.sendall((json.dumps(req) + "\n").encode()) @@ -70,6 +147,10 @@ def request(c, token, req): if not chunk: break data += chunk + if len(data) > _MAX_MSG: + raise RuntimeError( + f"IPC response exceeded {_MAX_MSG // (1024*1024)}MB cap — daemon malfunction?" + ) return json.loads(data or b"{}") @@ -111,26 +192,43 @@ def identify(name, timeout=1.0): async def serve(name, handler): - """Run the AF_UNIX server until cancelled.""" - path = str(_sock_path(name)) - if os.path.exists(path): - os.unlink(path) - # umask 0o077 makes bind() create the socket as 0600 — no TOCTOU window before chmod. - old_umask = os.umask(0o077) - try: - server = await asyncio.start_unix_server(handler, path=path) - finally: - os.umask(old_umask) + """Run the server until cancelled. Endpoint comes from IPH_BIND (or default AF_UNIX).""" + ep = bind_endpoint(name) + if ep[0] == "unix": + path = ep[1] + if os.path.exists(path): + os.unlink(path) + # umask 0o077 makes bind() create the socket as 0600 — no TOCTOU window before chmod. + old_umask = os.umask(0o077) + try: + server = await asyncio.start_unix_server(handler, path=path) + finally: + os.umask(old_umask) + else: + _, host, port = ep + if host not in _LOOPBACK: + print( + f"iphone-harness: WARNING — TCP daemon binding to {host}:{port} " + f"(non-loopback). RPC is unauthenticated; use an SSH tunnel " + f"(ssh -L {port}:127.0.0.1:{port} ) or restrict at firewall.", + file=sys.stderr, + ) + server = await asyncio.start_server(handler, host=host, port=port) async with server: await asyncio.Event().wait() def expected_token(): - """Always None on POSIX — AF_UNIX + chmod 600 is the boundary.""" + """Always None — AF_UNIX + chmod 600 (or TCP loopback) is the boundary.""" return None def cleanup_endpoint(name): - p = _sock_path(name) - try: p.unlink() - except FileNotFoundError: pass + """Remove the unix socket file. No-op for TCP.""" + ep = bind_endpoint(name) + if ep[0] != "unix": + return + try: + Path(ep[1]).unlink() + except FileNotFoundError: + pass diff --git a/iphone_harness/admin.py b/iphone_harness/admin.py index 77cdb4f..cd4e7a1 100644 --- a/iphone_harness/admin.py +++ b/iphone_harness/admin.py @@ -13,12 +13,45 @@ import urllib.error import urllib.request +from mobile_use._platform import ( + LINUX_LIBIMOBILEDEVICE_PKGS, + LINUX_NODE_PKGS, + install_hint, + is_linux, + is_macos, +) + from . import _ipc as ipc NAME = os.environ.get("IPH_NAME", "default") APPIUM_URL = os.environ.get("IPH_APPIUM_URL", "http://127.0.0.1:4723") +def is_remote_daemon(name=None): + """True when IPH_CONNECT points at a TCP daemon we don't manage locally + (Windows/Linux client-only mode driving a remote Mac running Appium+WDA). + + When True, ensure_daemon() never spawns or restarts — only pings. The + operator owns the remote daemon's lifecycle. Typical use: + + # on the Mac: + IPH_BIND=tcp://127.0.0.1:8763 iphone-harness -c 'pass' + # on Windows, via SSH tunnel: + ssh -L 8763:127.0.0.1:8763 mac + IPH_CONNECT=tcp://127.0.0.1:8763 mobile-use --ios -c '...' + + Or via the CLI flag: `mobile-use --ios --remote-daemon tcp://...`. + """ + spec = os.environ.get("IPH_CONNECT") + if not spec: + return False + try: + kind, *_ = ipc.parse_endpoint(spec) + except ValueError: + return False + return kind == "tcp" + + def _version(): try: from importlib.metadata import version @@ -31,9 +64,94 @@ def daemon_alive(name=None): return ipc.ping(name or NAME, timeout=1.0) +def _pid_alive(pid): + """True if a process with this pid exists.""" + # isinstance(True, int) is True in Python — reject bool to avoid os.kill(1, 0). + if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0: + return False + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True # process exists but not ours — don't wipe its files + except OSError: + return False + + +def cleanup_stale(name=None): + """Remove leftover .pid + (for AF_UNIX) .sock from a dead daemon. + + Called before spawn to keep stale state from a kill -9 / OOM / crash from + confusing the next ensure_daemon. Safe to call when no files exist or when + a live daemon owns them — it preserves anything still in use. + """ + name = name or NAME + pid_path = ipc.pid_path(name) + endpoint = ipc.bind_endpoint(name) + + # If a live daemon is responding, leave everything alone. + if ipc.ping(name, timeout=0.3): + return False + + cleaned = False + try: + recorded = int(pid_path.read_text().strip()) + except (FileNotFoundError, ValueError, UnicodeDecodeError, PermissionError, OSError): + recorded = None + + if recorded is not None and not _pid_alive(recorded): + try: + pid_path.unlink() + cleaned = True + except FileNotFoundError: + pass + elif recorded is None and pid_path.exists(): + try: + pid_path.unlink() + cleaned = True + except FileNotFoundError: + pass + + # TCP endpoints (tcp://...) have no socket file — only AF_UNIX needs cleanup. + if endpoint[0] == "unix": + from pathlib import Path as _P + sock_path = _P(endpoint[1]) + if sock_path.exists(): + try: + sock_path.unlink() + cleaned = True + except FileNotFoundError: + pass + + return cleaned + + def ensure_daemon(wait=30.0, name=None, env=None): - """Spawn the daemon if no live one is reachable. Idempotent.""" + """Spawn the daemon if no live one is reachable. Idempotent. + + In client-only mode (IPH_CONNECT=tcp://:port — e.g. from a + Windows or Linux host driving a remote Mac), this NEVER spawns locally. + Only pings; on failure raises a remote-side checklist. + """ name = name or NAME + + if is_remote_daemon(name): + spec = os.environ.get("IPH_CONNECT", "") + if daemon_alive(name): + return + raise RuntimeError( + f"iphone-harness: remote daemon unreachable at {spec}.\n" + f" This host is in client-only mode (IPH_CONNECT set).\n" + f" Checks on the remote Mac:\n" + f" - daemon running? (ssh mac 'pgrep -fa iphone_harness.daemon')\n" + f" - bound to TCP? (ssh mac 'lsof -iTCP -sTCP:LISTEN | grep python')\n" + f" - reachable port? (Windows: `Test-NetConnection -ComputerName -Port `)\n" + f" Tip: prefer `ssh -L 8763:127.0.0.1:8763 ` over exposing the\n" + f" daemon on 0.0.0.0 — the RPC is unauthenticated." + ) + if daemon_alive(name): # Live ping is enough — but verify the Appium-side handshake too. A # daemon whose webdriver session died still answers meta:* but errors @@ -50,9 +168,14 @@ def ensure_daemon(wait=30.0, name=None, env=None): pass restart_daemon(name) + # Stale .sock or .pid from a hard-killed previous daemon would confuse spawn. + cleanup_stale(name) + e = {**os.environ, **({"IPH_NAME": name} if name else {}), **(env or {})} + # IPH_DAEMON_MODULE is a test-only escape hatch; defaults to real daemon. + module = e.get("IPH_DAEMON_MODULE", "iphone_harness.daemon") p = subprocess.Popen( - [sys.executable, "-m", "iphone_harness.daemon"], + [sys.executable, "-m", module], env=e, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **ipc.spawn_kwargs(), ) @@ -109,9 +232,21 @@ def restart_daemon(name=None): os.kill(daemon_pid, signal.SIGTERM) except (ProcessLookupError, PermissionError): pass - time.sleep(0.5) + # Wait up to 2s for SIGTERM to settle. + deadline = time.time() + 2.0 + while time.time() < deadline and _pid_alive(daemon_pid): + time.sleep(0.1) + + # Step 4: SIGKILL the daemon if it's still alive after SIGTERM. + if daemon_pid and _pid_alive(daemon_pid): + try: + os.kill(daemon_pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + # Reap zombie state on local process. + time.sleep(0.2) - # Step 4: cleanup pid + sock files. + # Step 5: cleanup pid + sock files. try: os.unlink(pid_path) except FileNotFoundError: pass ipc.cleanup_endpoint(name) @@ -152,14 +287,42 @@ def _check_device(): return True, f"paired ({udid})" return False, f"udid {udid} not in `idevice_id -l`: {out!r}" except FileNotFoundError: - return False, "`idevice_id` not installed (brew install libimobiledevice)" + hint = install_hint("libimobiledevice", LINUX_LIBIMOBILEDEVICE_PKGS) + return False, f"`idevice_id` not installed ({hint})" except Exception as e: return False, str(e) +def _check_libimobiledevice(): + """Return (True, info) if libimobiledevice tools are available. + + On macOS, asks Homebrew. On Linux, checks `idevice_id` on PATH (typical + apt/dnf/pacman libimobiledevice package installs this binary). Linux + users don't have brew — checking the binary directly is the honest + test for "are the tools usable". + """ + if is_macos(): + brew = shutil.which("brew") + if brew is None: + return False, "brew not installed" + try: + out = subprocess.check_output([brew, "list", "--versions", "libimobiledevice"], + timeout=4.0, stderr=subprocess.DEVNULL).decode().strip() + return (True, out) if out else (False, "libimobiledevice not installed") + except subprocess.CalledProcessError: + return False, "libimobiledevice not installed" + except Exception as e: + return False, str(e) + if is_linux(): + if shutil.which("idevice_id") is None: + return False, "libimobiledevice (`idevice_id`) not on PATH" + return True, shutil.which("idevice_id") + return True, "(skipped — non-macOS / non-Linux host)" + + def _check_brew_pkg(pkg): - """Return (True, version) if Homebrew has `pkg` installed, else False.""" - if sys.platform != "darwin": + """Legacy shim — kept so external callers + tests still import this.""" + if not is_macos(): return True, "(skipped — non-macOS)" brew = shutil.which("brew") if brew is None: @@ -264,9 +427,9 @@ def _check_python_pkg(): def _check_xcode(): - """Return (True, version) if Xcode is selected (`xcodebuild -version`).""" - if sys.platform != "darwin": - return True, "(skipped — non-macOS)" + """Return (True, version) if Xcode is selected. Auto-skipped off macOS.""" + if not is_macos(): + return True, "(skipped — Xcode is macOS-only; drive iOS from Linux via remote IPH_APPIUM_URL)" if shutil.which("xcodebuild") is None: return False, "xcodebuild not on PATH" try: @@ -277,22 +440,66 @@ def _check_xcode(): return False, str(e) +def _check_wda_signing(): + """Return (True, state) if WDA is signed. Auto-skipped off macOS — signing is Xcode-only.""" + if not is_macos(): + return True, "(skipped — WDA signing requires macOS + Xcode)" + try: + from mobile_use.ios_wda import check_wda_signing + state, details = check_wda_signing() + if state == "signed": + return True, details + return False, f"{state}: {details}" + except Exception as e: + return False, f"WDA check failed: {e}" + + +def _check_battery(): + """Return (True, level%) if device battery > 20%. Falls back to skipped if no tool.""" + udid = os.environ.get("IPH_UDID") + if not udid: + return True, "(skipped — IPH_UDID not set)" + if shutil.which("ideviceinfo") is None: + return True, "(skipped — ideviceinfo not installed)" + try: + # `ideviceinfo -k BatteryCurrentCapacity` returns 0-100 + out = subprocess.check_output( + ["ideviceinfo", "-u", udid, "-q", "com.apple.mobile.battery", "-k", "BatteryCurrentCapacity"], + timeout=5.0, stderr=subprocess.DEVNULL, + ).decode().strip() + try: + level = int(out) + except (ValueError, TypeError): + return True, f"(skipped — battery level unreadable: {out!r})" + if level < 20: + return True, f"{level}% (WARN: low — plug in to avoid disconnect)" + return True, f"{level}%" + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError, FileNotFoundError): + return True, "(skipped — battery info unavailable)" + + def run_doctor(): """Diagnostic. Prints status of each external dependency. Returns 0 on all-green.""" print(f"iphone-harness {_version() or '(dev)'}\n") rc = 0 + libimobiledevice_hint = install_hint("libimobiledevice ideviceinstaller", LINUX_LIBIMOBILEDEVICE_PKGS) + node_hint = install_hint("node", LINUX_NODE_PKGS) + xcode_hint = ("(skipped — Linux host. To drive iOS from Linux, set " + "IPH_APPIUM_URL=http://:4723 and run Appium on the Mac.)" + if is_linux() else + "Install Xcode from App Store, then `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`") checks = [ - ("Homebrew package: libimobiledevice", _check_brew_pkg, ("libimobiledevice",), - "brew install libimobiledevice ideviceinstaller"), + ("libimobiledevice + ideviceinstaller", _check_libimobiledevice, (), + libimobiledevice_hint), ("Node.js + npm", _check_node, (), - "brew install node"), + node_hint), ("Appium installed", _check_appium_installed, (), "npm i -g appium"), ("Appium xcuitest driver", _check_driver_installed, ("xcuitest",), "appium driver install xcuitest (or: appium-xcuitest-driver@10.43.1)"), ("Xcode (selected)", _check_xcode, (), - "Install Xcode from App Store, then `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`"), + xcode_hint), ("Python package installed (pip install -e .)", _check_python_pkg, (), "Run from repo root: pip install -e . (or `mobile-use bootstrap`)"), ("`iphone-harness` CLI on PATH", _check_cli_on_path, ("iphone-harness",), @@ -303,6 +510,10 @@ def run_doctor(): f"Start Appium with `appium --base-path /` (port 4723; URL: {APPIUM_URL})"), ("iPhone paired + Developer Mode on", _check_device, (), "Plug in iPhone, `Trust This Computer` if prompted, Settings → Privacy & Security → Developer Mode → On"), + ("WebDriverAgent signed (provisioning profile present + not expired)", _check_wda_signing, (), + "Run `mobile-use ios sign-wda` — opens Xcode for the 6-step signing dance."), + ("Device battery level (>20% recommended)", _check_battery, (), + "Plug in the iPhone to charge. Low battery causes USB disconnects during long sessions."), ] total = len(checks) + 2 diff --git a/iphone_harness/daemon.py b/iphone_harness/daemon.py index 5755184..e4b65f5 100644 --- a/iphone_harness/daemon.py +++ b/iphone_harness/daemon.py @@ -102,6 +102,13 @@ def __init__(self): # Pool a single thread for blocking driver calls so Appium's HTTP # client doesn't fight asyncio's event loop. One driver, one worker. self._loop = None + # Screen-stream state — populated by screen_stream_start RPC. + self._stream_task = None + self._stream_frame = None # latest JPEG bytes + self._stream_frame_no = 0 # increments per capture; viewer detects drops + self._stream_fps = 6.0 + self._stream_quality = 60 + self._stream_max_dim = 800 # largest side in px; thumbnailed async def _drive(self, fn): """Run a blocking driver callable in the default executor.""" @@ -199,6 +206,81 @@ async def _m_screenshot(d, params): return {"path": path, "bytes": len(png)} +# ---- live screen stream (powers --headed viewer) -------------------------- +# Producer (frame loop) lives in the daemon; consumer (HTTP MJPEG sidecar) +# pulls one frame at a time via screen_stream_frame. Single-consumer for v1. + +async def _stream_loop(d): + """Capture frames at d._stream_fps; JPEG-encode; store latest. Log + continue on errors.""" + import io + try: + from PIL import Image + except ImportError: + log("stream: Pillow not installed — install via `pip install pillow`") + return + while True: + period = 1.0 / max(0.1, d._stream_fps) + try: + png = await d._drive(d.driver.get_screenshot_as_png) + img = Image.open(io.BytesIO(png)) + if d._stream_max_dim and max(img.size) > d._stream_max_dim: + img.thumbnail((d._stream_max_dim, d._stream_max_dim)) + buf = io.BytesIO() + img.convert("RGB").save(buf, format="JPEG", quality=d._stream_quality) + d._stream_frame = buf.getvalue() + d._stream_frame_no += 1 + except asyncio.CancelledError: + raise + except Exception as e: + log(f"stream: capture failed: {e}") + await asyncio.sleep(period) + + +async def _m_screen_stream_start(d, params): + """Start (or reconfigure) the capture loop. params: {fps, quality, max_dim}.""" + fps = float(params.get("fps", 6)) + quality = max(1, min(95, int(params.get("quality", 60)))) + max_dim = int(params.get("max_dim", 800)) + d._stream_fps = fps + d._stream_quality = quality + d._stream_max_dim = max_dim + if d._stream_task is not None and not d._stream_task.done(): + return {"running": True, "updated": True, "fps": fps, + "quality": quality, "max_dim": max_dim} + d._stream_frame_no = 0 + d._stream_task = asyncio.create_task(_stream_loop(d)) + return {"running": True, "started": True, "fps": fps, + "quality": quality, "max_dim": max_dim} + + +async def _m_screen_stream_frame(d, params): + """Return latest captured frame as base64 JPEG. Single-consumer pull model.""" + import base64 + if d._stream_frame is None: + return {"ready": False, "frame_no": 0} + return { + "ready": True, + "frame_no": d._stream_frame_no, + "jpeg_b64": base64.b64encode(d._stream_frame).decode("ascii"), + "fps": d._stream_fps, + "quality": d._stream_quality, + } + + +async def _m_screen_stream_stop(d, params): + """Cancel the capture loop and clear buffered frame. Idempotent.""" + if d._stream_task is None: + return {"running": False} + d._stream_task.cancel() + try: + await d._stream_task + except (asyncio.CancelledError, Exception): + pass + d._stream_task = None + d._stream_frame = None + return {"running": False, "stopped": True} + + async def _m_page_source(d, params): """Raw XML UI tree from WebDriverAgent. Helpers parse it client-side.""" return await d._drive(lambda: d.driver.page_source) @@ -327,6 +409,10 @@ def _do(): "send_keys": _m_send_keys, "set_value": _m_set_value, "pick_wheel": _m_pick_wheel, + # Live screen mirror — powers `mobile-use --headed`. + "screen_stream_start": _m_screen_stream_start, + "screen_stream_frame": _m_screen_stream_frame, + "screen_stream_stop": _m_screen_stream_stop, } diff --git a/iphone_harness/helpers.py b/iphone_harness/helpers.py index d504937..665ca18 100644 --- a/iphone_harness/helpers.py +++ b/iphone_harness/helpers.py @@ -122,6 +122,105 @@ def appium(script, **args): return _send({"method": "appium", "params": {"script": script, "args": args}})["result"] +# ---- device state + recovery ----------------------------------------------- + +class DeviceDisconnectError(RuntimeError): + """Device became unreachable mid-call (USB disconnect, Appium timeout, WDA crash).""" + + +def is_locked(): + """True if screen is locked (passcode shown or display off).""" + try: + return bool(appium("mobile: isLocked")) + except Exception: + # Older XCUITest: fall back to deviceInfo. + try: + info = appium("mobile: deviceInfo") + return bool(info.get("displayLocked", False)) if isinstance(info, dict) else False + except Exception: + return False + + +def wake_device(): + """Wake screen + dismiss lock screen if shown. No-op if already unlocked. + + Returns True if the device is unlocked after the call, False otherwise. + Use before any script that needs to interact when the device may have + timed out to lock. Pairs well with @retry_on_disconnect. + """ + if not is_locked(): + return True # already awake + unlocked = False + try: + appium("mobile: unlock") + unlocked = True + except Exception: + # No passcode set, or WDA hiccup — try power button as fallback. + try: + appium("mobile: pressButton", name="power") + unlocked = True + except Exception: + unlocked = False + if not unlocked: + return False + # Confirm post-state — unlock() may have returned without actually unlocking. + try: + return not is_locked() + except Exception: + return False + + +def retry_on_disconnect(max_attempts=3, backoff=0.5): + """Decorator: retry on device-disconnect errors with backoff + wake. + + Catches RuntimeError messages matching common disconnect signals and + transparently restarts the daemon + wakes the device before each retry. + + Usage: + @retry_on_disconnect(max_attempts=3) + def send_message(text): + tap(find(label='Compose')) + type_text(text) + """ + if not isinstance(max_attempts, int) or max_attempts < 1: + raise ValueError(f"max_attempts must be >= 1, got {max_attempts!r}") + if not isinstance(backoff, (int, float)) or backoff < 0: + raise ValueError(f"backoff must be >= 0, got {backoff!r}") + DISCONNECT_PATTERNS = ( + "unreachable", "disconnect", "session", "stale", + "connection", "timed out", "WebDriver", "wda", + ) + + def decorator(fn): + def wrapper(*args, **kwargs): + last_err = None + for attempt in range(max_attempts): + try: + return fn(*args, **kwargs) + except RuntimeError as e: + msg = str(e).lower() + if not any(p.lower() in msg for p in DISCONNECT_PATTERNS): + raise + last_err = e + if attempt < max_attempts - 1: + time.sleep(backoff * (2 ** attempt)) + try: + from .admin import restart_daemon, ensure_daemon + restart_daemon() + ensure_daemon() + wake_device() + except Exception: + pass + raise DeviceDisconnectError( + f"{fn.__name__} failed after {max_attempts} attempts. Last error: {last_err}\n" + f"Run `iphone-harness --doctor` to diagnose." + ) from last_err + wrapper.__name__ = fn.__name__ + wrapper.__doc__ = fn.__doc__ + return wrapper + return decorator + + # ---- perception ------------------------------------------------------------ ASSISTIVE_TOUCH_X = 390 @@ -269,6 +368,40 @@ def screenshot(path=None): return r["path"] +# ---- live screen stream (consumed by viewer/server.py) -------------------- + +def screen_stream_start(fps=6, quality=60, max_dim=800): + """Start the daemon's screen-capture loop. Idempotent — calling again with + different knobs reconfigures the running loop. Returns the daemon's reply + dict (running, fps, quality, max_dim, started?|updated?).""" + _drop_conn() + r = _send({ + "method": "screen_stream_start", + "params": {"fps": fps, "quality": quality, "max_dim": max_dim}, + }) + return r.get("result", {"running": False}) + + +def screen_stream_frame(): + """Pull the latest JPEG frame from the daemon. Returns a dict: + {ready: bool, frame_no: int, jpeg_b64?: str, fps?, quality?} + Decode jpeg_b64 via base64.b64decode → JPEG bytes. + + Drops the cached socket first — daemons close the conn after every reply, + so the cache from the previous start/frame call is dead by now and would + silently return an empty response on the next request.""" + _drop_conn() + r = _send({"method": "screen_stream_frame", "params": {}}, timeout=10.0) + return r.get("result", {"ready": False, "frame_no": 0}) + + +def screen_stream_stop(): + """Cancel the daemon's capture loop. Idempotent.""" + _drop_conn() + r = _send({"method": "screen_stream_stop", "params": {}}) + return r.get("result", {"running": False}) + + def ocr(image_path=None, languages=("en-US",)): """Apple Vision OCR on a PNG. macOS-only — uses the system Vision framework via PyObjC; no network, no API keys. @@ -284,12 +417,20 @@ def ocr(image_path=None, languages=("en-US",)): """ if image_path is None: image_path = screenshot() + from mobile_use._platform import OCRNotAvailableError, is_macos + if not is_macos(): + raise OCRNotAvailableError( + "ocr() uses the macOS Vision framework — not bundled on this host. " + "Linux: install Tesseract (`sudo apt install tesseract-ocr` or " + "equivalent) and wrap it yourself, or run mobile_use from a macOS " + "host. See SETUP.md#ocr-on-linux for details." + ) try: import Vision from Foundation import NSURL except ImportError as e: - raise RuntimeError( - "ocr() needs PyObjC (macOS only). Install: pip install pyobjc-framework-Vision" + raise OCRNotAvailableError( + "ocr() needs PyObjC. Install: pip install pyobjc-framework-Vision" ) from e url = NSURL.fileURLWithPath_(image_path) @@ -656,6 +797,46 @@ def swipe(x1, y1, x2, y2, duration=0.4): fromX=x1, fromY=y1, toX=x2, toY=y2) +def press_home(): + """Go to the home screen. iOS equivalent of Android's press_home().""" + appium("mobile: pressButton", name="home") + + +def swipe_back(): + """Pop the current view by swiping from the left edge. + + iOS has no hardware back button — UINavigationController honors the + edge-swipe-from-left gesture. This is the closest equivalent to + Android's press_back() for in-app navigation. + """ + sz = window_size() + swipe(2, sz["height"] // 2, sz["width"] // 2, sz["height"] // 2, duration=0.3) + + +def press_back(): + """Alias for swipe_back() — provided for API symmetry with Android. + + iOS has no hardware Back button; this triggers the swipe-from-left + gesture that pops the current UINavigationController view. + """ + swipe_back() + + +def open_app_switcher(): + """Show the iOS App Switcher (swipe up from bottom, pause). + + Android equivalent: press_recents(). + """ + sz = window_size() + swipe(sz["width"] // 2, sz["height"] - 2, sz["width"] // 2, sz["height"] // 2, duration=0.6) + wait(0.6) + + +def press_recents(): + """Alias for open_app_switcher() — API symmetry with Android.""" + open_app_switcher() + + def scroll(direction="down", x=None, y=None): """Scroll the current scroll view. `direction` ∈ {up, down, left, right}. Pass x, y to scroll within a specific element if needed (XCUITest infers if omitted). @@ -1010,6 +1191,56 @@ def paste_text(text, predicate=None, index=0): # ---- screen recording ------------------------------------------------------ +import base64 as _base64 + +def record_screen(duration=10, path=None, fps=10, quality="medium"): + """Record the device screen for `duration` seconds. Returns the host path. + + Uses Appium's `mobile: startRecordingScreen` + `mobile: stopRecordingScreen` + (XCUITest backend), which returns base64-encoded MP4. We decode and write + to `path` (or a /tmp default). + + Args: + duration: positive seconds to record (XCUITest caps at 1800s ≈ 30min) + path: host filesystem path; defaults to /tmp/iph-record-.mp4 + fps: frame rate (default 10) + quality: 'low' | 'medium' | 'high' | 'photo' + + Returns: + Path string of the saved .mp4 file. + """ + if not isinstance(duration, (int, float)) or duration <= 0: + raise ValueError(f"record_screen duration must be > 0, got {duration!r}") + if duration > 1800: + raise ValueError(f"record_screen duration must be <= 1800s (got {duration}); XCUITest cap") + if path is None: + path = str(ipc._TMP / f"iph-record-{int(time.time())}.mp4") + # Ensure parent dir exists + path isn't a directory. + import os as _os + if _os.path.isdir(path): + raise IsADirectoryError(f"record_screen path is a directory: {path}") + parent = _os.path.dirname(_os.path.abspath(path)) + if parent and not _os.path.exists(parent): + _os.makedirs(parent, exist_ok=True) + try: + appium("mobile: startRecordingScreen", timeLimit=int(duration) + 5, + videoFps=int(fps), videoQuality=quality) + except Exception as e: + raise RuntimeError( + f"start screen recording failed: {e}\n" + f" Likely cause: XCUITest version doesn't support `mobile: startRecordingScreen`.\n" + f" Try: `appium driver install --source=npm appium-xcuitest-driver@latest`." + ) + time.sleep(duration) + try: + b64 = appium("mobile: stopRecordingScreen") + except Exception as e: + raise RuntimeError(f"stop screen recording failed: {e}") + with open(path, "wb") as f: + f.write(_base64.b64decode(b64)) + return path + + def start_screen_recording(): """Start a screen recording. Installs the CC tile if missing. diff --git a/mobile_use/_platform.py b/mobile_use/_platform.py new file mode 100644 index 0000000..364f957 --- /dev/null +++ b/mobile_use/_platform.py @@ -0,0 +1,254 @@ +"""Platform detection + Linux package-manager helpers. + +Single source of truth for `sys.platform` and Linux distro detection. +Other modules (bootstrap, doctor checks, OCR fallback) import from here +instead of sprinkling `sys.platform == "darwin"` checks throughout. + +Supported Linux package managers: apt, dnf, pacman, zypper, apk. Anything +else returns None — callers handle that as "manual install required" and +print apt/dnf/pacman one-liners as a fallback hint. + +iOS is locked to macOS (Xcode + Apple codesigning). Linux drives iOS only +via a remote macOS Appium server — there is no local Linux path to a built +WebDriverAgent. Callers that need iOS-on-Linux check `IPH_APPIUM_URL` +pointing at a non-localhost host (the remote macOS Appium). +""" +import os +import shutil +import sys +from pathlib import Path + + +def is_linux() -> bool: + return sys.platform == "linux" + + +def is_macos() -> bool: + return sys.platform == "darwin" + + +def is_windows() -> bool: + """Native Windows (not WSL — WSL reports as linux). iOS work requires a + remote Mac daemon — Windows hosts cannot build/sign WebDriverAgent.""" + return sys.platform == "win32" + + +def needs_remote_mac_for_ios() -> bool: + """True when this host cannot run xcodebuild + libimobiledevice locally + (anything except macOS). Such hosts can still drive iOS via + IPH_CONNECT=tcp://: pointed at a remote macOS daemon.""" + return not is_macos() + + +def windows_ios_setup_hint() -> str: + """Multi-line guidance shown on Windows when iOS work is attempted without + IPH_CONNECT set.""" + return ( + "iOS control from Windows requires a Mac on the network running\n" + "Appium + a built WebDriverAgent + the iphone-harness daemon bound\n" + "to TCP. Steps:\n" + " 1. On the Mac: `mobile-use bootstrap --ios-only` then\n" + " `mobile-use ios build-wda` (one-time signing in Xcode).\n" + " 2. On the Mac: start daemon over TCP --\n" + " `IPH_BIND=tcp://127.0.0.1:8763 iphone-harness -c 'pass'`\n" + " then `ssh -L 8763:127.0.0.1:8763 mac` from Windows.\n" + " 3. On Windows: point the CLI at the tunneled daemon --\n" + " `mobile-use --ios --remote-daemon tcp://127.0.0.1:8763 -c ...`\n" + "Docs: see SETUP.md -> 'iOS from Windows / Linux (remote Mac bridge)'." + ) + + +def linux_pkg_manager(): + """Return 'apt' | 'dnf' | 'pacman' | 'zypper' | 'apk' | None. + + Reads /etc/os-release ID + ID_LIKE for portability across derivatives: + Debian/Ubuntu/Mint/Pop/Raspbian → apt + Fedora/RHEL/CentOS/Rocky/Alma → dnf + Arch/Manjaro/EndeavourOS → pacman + openSUSE/SLES → zypper + Alpine → apk + + Falls back to PATH lookup when /etc/os-release is unreadable. + """ + if not is_linux(): + return None + + ids = set() + try: + for line in Path("/etc/os-release").read_text().splitlines(): + if "=" not in line: + continue + k, v = line.split("=", 1) + v = v.strip().strip('"').strip("'") + if k == "ID": + ids.add(v) + elif k == "ID_LIKE": + ids.update(v.split()) + except (FileNotFoundError, OSError): + pass + + apt_like = {"debian", "ubuntu", "mint", "pop", "raspbian"} + dnf_like = {"fedora", "rhel", "centos", "rocky", "almalinux"} + pacman_like = {"arch", "manjaro", "endeavouros"} + zypper_like = {"opensuse", "opensuse-leap", "opensuse-tumbleweed", "sles", "suse"} + apk_like = {"alpine"} + + if ids & apt_like or shutil.which("apt"): + return "apt" + if ids & dnf_like or shutil.which("dnf"): + return "dnf" + if ids & pacman_like or shutil.which("pacman"): + return "pacman" + if ids & zypper_like or shutil.which("zypper"): + return "zypper" + if ids & apk_like or shutil.which("apk"): + return "apk" + return None + + +def sudo_prefix(): + """Return ['sudo'] if needed and available, [] if root, None if missing. + + Same shape as the legacy `_sudo_prefix` in bootstrap.py — preserved so + callers can detect "we need root but can't get it" via `is None`. + """ + if not is_linux(): + return [] + try: + if os.geteuid() == 0: + return [] + except AttributeError: + return [] + if shutil.which("sudo") is None: + return None + return ["sudo"] + + +# Per-manager package names. Other modules import these directly when they +# need a specific tool, rather than re-deriving the mapping each time. +LINUX_ADB_PKGS = { + "apt": ["android-tools-adb"], + "dnf": ["android-tools"], + "pacman": ["android-tools"], + "zypper": ["android-tools"], + "apk": ["android-tools"], +} +LINUX_NODE_PKGS = { + "apt": ["nodejs", "npm"], + "dnf": ["nodejs", "npm"], + "pacman": ["nodejs", "npm"], + "zypper": ["nodejs", "npm"], + "apk": ["nodejs", "npm"], +} +LINUX_LIBIMOBILEDEVICE_PKGS = { + "apt": ["libimobiledevice6", "libimobiledevice-utils", "usbmuxd"], + "dnf": ["libimobiledevice", "libimobiledevice-utils", "usbmuxd"], + "pacman": ["libimobiledevice", "usbmuxd"], + "zypper": ["libimobiledevice", "libimobiledevice-tools", "usbmuxd"], + "apk": ["libimobiledevice"], +} + + +_UNSET = object() + + +def linux_install_cmd(pkgs_per_manager, manager=None, prefix=_UNSET): + """Build the install argv for the current Linux distro, or None. + + `pkgs_per_manager` maps manager name → list[str] of package names. Use + the module constants (LINUX_ADB_PKGS, LINUX_NODE_PKGS, …) where they fit. + + Returns None when: + - No supported package manager detected (and manager= not passed) + - Sudo needed but unavailable + - This manager has no entry in the supplied map + + When `manager` is explicitly passed, the host-platform check is skipped + (callers who know what they want — e.g. unit tests, dry-run plan + generation — bypass auto-detection). When `prefix` is passed, sudo + auto-detection is skipped (use `[]` for "no prefix", `None` for "no + sudo available — return None"). + + Example:: + + argv = linux_install_cmd(LINUX_ADB_PKGS) + # ['sudo', 'apt', 'install', '-y', 'android-tools-adb'] + """ + pm = manager or linux_pkg_manager() + if pm is None or pm not in pkgs_per_manager: + return None + if prefix is _UNSET: + prefix = sudo_prefix() + if prefix is None: + return None + pkgs = pkgs_per_manager[pm] + if pm == "apt": + return prefix + ["apt", "install", "-y", *pkgs] + if pm == "dnf": + return prefix + ["dnf", "install", "-y", *pkgs] + if pm == "pacman": + return prefix + ["pacman", "-S", "--noconfirm", *pkgs] + if pm == "zypper": + return prefix + ["zypper", "install", "-y", *pkgs] + if pm == "apk": + return prefix + ["apk", "add", *pkgs] + return None + + +class OCRNotAvailableError(RuntimeError): + """Raised when ocr() is invoked on a host that has no OCR backend. + + Currently macOS is the only platform with a bundled OCR backend + (Apple Vision via PyObjC). Linux + other hosts must install Tesseract + or another OCR engine themselves. The error message points at + SETUP.md for the install path. + """ + + +def install_hint(brew_pkg: str, pkgs_per_manager: dict) -> str: + """One-line install command for the current host. + + On macOS → `brew install {brew_pkg}` (the original behavior). + On Linux → the apt/dnf/pacman/zypper/apk command for the detected pkg + manager, or a multi-line list of all five if the manager is unknown. + + Used in doctor remediation messages so Linux users don't see + `brew install …` they can't act on. + """ + if is_macos(): + return f"brew install {brew_pkg}" + if not is_linux(): + return f"(install {brew_pkg} via your OS package manager)" + pm = linux_pkg_manager() + if pm and pm in pkgs_per_manager: + pkgs = " ".join(pkgs_per_manager[pm]) + if pm == "apt": + return f"sudo apt install {pkgs}" + if pm == "dnf": + return f"sudo dnf install {pkgs}" + if pm == "pacman": + return f"sudo pacman -S {pkgs}" + if pm == "zypper": + return f"sudo zypper install {pkgs}" + if pm == "apk": + return f"sudo apk add {pkgs}" + # Unknown distro — show all known managers so the user can pick. + lines = ["install via your distro's package manager — options:"] + for mgr in ("apt", "dnf", "pacman", "zypper", "apk"): + if mgr in pkgs_per_manager: + pkgs = " ".join(pkgs_per_manager[mgr]) + verb = {"apt": "apt install", "dnf": "dnf install", + "pacman": "pacman -S", "zypper": "zypper install", + "apk": "apk add"}[mgr] + lines.append(f" - {mgr}: sudo {verb} {pkgs}") + return "\n".join(lines) + + +def host_os_label() -> str: + """Short label for the current host. Used in logs and doctor headers.""" + if is_macos(): + return "macOS" + if is_linux(): + pm = linux_pkg_manager() + return f"Linux ({pm})" if pm else "Linux (unknown pkg manager)" + return sys.platform diff --git a/mobile_use/agent_loop.py b/mobile_use/agent_loop.py index e95e552..6e2d2cc 100644 --- a/mobile_use/agent_loop.py +++ b/mobile_use/agent_loop.py @@ -248,5 +248,26 @@ def run_agent(platform=None, args=None): session_name = args[i + 1] break + # --headed: spin up MJPEG viewer + open browser. Stays up for whole REPL. + viewer = None + if os.environ.get("MOBILE_USE_HEADED") == "1": + try: + from .viewer.server import ViewerServer + viewer = ViewerServer(platform=platform) + viewer.start() + print(f"[mobile-use] live viewer at {viewer.url}", file=sys.stderr) + try: + import webbrowser + webbrowser.open(viewer.url) + except Exception: + pass + except Exception as e: + print(f"[mobile-use] viewer failed to start: {e} (continuing)", + file=sys.stderr) + agent = AgentLoop(platform=platform, session_name=session_name) - agent.run_interactive() + try: + agent.run_interactive() + finally: + if viewer is not None: + viewer.stop() diff --git a/mobile_use/bootstrap.py b/mobile_use/bootstrap.py index beb0e29..3dad3dc 100644 --- a/mobile_use/bootstrap.py +++ b/mobile_use/bootstrap.py @@ -7,7 +7,9 @@ Does NOT install Xcode (gates on it with clear instructions). Does NOT install Python — assumes the user invoked this with python3. -Non-macOS: brew steps are skipped with a per-OS hint (apt, dnf, pacman). +Linux: Android path works (adb is cross-platform). iOS requires macOS+Xcode, +so iOS steps are skipped on Linux with a clear message. Detects apt/dnf/pacman +via /etc/os-release ID/ID_LIKE and emits the right install command. """ import os import shutil @@ -15,6 +17,20 @@ import sys from pathlib import Path +# `os` is re-exported via `bootstrap.os` so existing tests that monkeypatch +# `bootstrap.os.geteuid` keep working after the _platform refactor. +assert os.path is not None # noqa: S101 — silences pyright unused-import without removing the symbol + +from . import _platform +from ._platform import ( + LINUX_ADB_PKGS, + LINUX_LIBIMOBILEDEVICE_PKGS, + LINUX_NODE_PKGS, + is_linux, + is_macos, + linux_install_cmd, +) + REPO_ROOT = Path(__file__).resolve().parent.parent @@ -28,8 +44,54 @@ def _have(cmd): return shutil.which(cmd) is not None +# Back-compat shims — tests + external callers still reference these names. +def _linux_pkg_manager(): + return _platform.linux_pkg_manager() + + +def _sudo_prefix(): + return _platform.sudo_prefix() + + +def _linux_adb_install_cmd(): + return linux_install_cmd(LINUX_ADB_PKGS, + manager=_linux_pkg_manager(), + prefix=_sudo_prefix()) + + +def _linux_node_install_cmd(): + return linux_install_cmd(LINUX_NODE_PKGS, + manager=_linux_pkg_manager(), + prefix=_sudo_prefix()) + + +def _linux_libimobiledevice_install_cmd(): + return linux_install_cmd(LINUX_LIBIMOBILEDEVICE_PKGS, + manager=_linux_pkg_manager(), + prefix=_sudo_prefix()) + + +def _have_xcode(): + """True if a usable Xcode is installed (not just Command Line Tools). + + `xcodebuild -version` only succeeds when full Xcode is selected. CLT-only + setups return an error like 'xcode-select: error: tool xcodebuild requires Xcode'. + """ + if not is_macos(): + return True # iOS gating handles this; xcode irrelevant elsewhere + if not _have("xcodebuild"): + return False + try: + out = subprocess.check_output( + ["xcodebuild", "-version"], timeout=5.0, stderr=subprocess.STDOUT + ).decode() + return "Xcode" in out + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + return False + + def _brew_has(pkg): - if sys.platform != "darwin" or not _have("brew"): + if not is_macos() or not _have("brew"): return False try: subprocess.check_output(["brew", "list", "--versions", pkg], @@ -64,12 +126,22 @@ def _python_pkg_importable(): def plan(ios=True, android=True): """Return a list of (label, check_fn, run_cmd_list, mac_only) tuples for the current state. Steps already satisfied stay in the list w/ a True - check so the caller can show 'OK' for them.""" + check so the caller can show 'OK' for them. + + On Linux: iOS-side steps still emit (gated by mac_only → SKIP at run time). + Android-side steps emit a Linux-native install command via the host's + apt/dnf/pacman. + """ steps = [] + on_linux = is_linux() if ios: + steps.append(("Xcode (full app, not just Command Line Tools)", + _have_xcode, + None, # cannot auto-install Xcode; App Store only + True)) steps.append(("Homebrew (macOS package manager)", - lambda: _have("brew") or sys.platform != "darwin", + lambda: _have("brew") or not is_macos(), None, # cannot auto-install brew; print message True)) steps.append(("brew install libimobiledevice (idevice_id, ideviceinstaller)", @@ -77,14 +149,27 @@ def plan(ios=True, android=True): ["brew", "install", "libimobiledevice", "ideviceinstaller"], True)) if android: - steps.append(("brew install android-platform-tools (adb)", - lambda: _brew_has("android-platform-tools") or _have("adb"), - ["brew", "install", "android-platform-tools"], + if on_linux: + steps.append(("Android Platform Tools (adb) — Linux", + lambda: _have("adb"), + _linux_adb_install_cmd(), + False)) + else: + steps.append(("brew install android-platform-tools (adb)", + lambda: _brew_has("android-platform-tools") or _have("adb"), + ["brew", "install", "android-platform-tools"], + True)) + # Node + npm — macOS via brew; Linux via apt/dnf/pacman. + if on_linux: + steps.append(("Node.js + npm — Linux", + lambda: _have("node") and _have("npm"), + _linux_node_install_cmd(), + False)) + else: + steps.append(("Node.js + npm", + lambda: _have("node") and _have("npm"), + ["brew", "install", "node"], True)) - steps.append(("Node.js + npm", - lambda: _have("node") and _have("npm"), - ["brew", "install", "node"], - True)) steps.append(("Appium (CLI + server)", lambda: _have("appium"), ["npm", "i", "-g", "appium"], @@ -119,8 +204,12 @@ def run(ios=True, android=True, dry_run=False): for i, (label, check, cmd, mac_only) in enumerate(steps, start=1): prefix = f"[{i}/{len(steps)}]" - if mac_only and sys.platform != "darwin": - print(f"{prefix} {label}: SKIP (non-macOS — install via your OS pkg manager)") + if mac_only and not is_macos(): + if "iOS" in label or "Xcode" in label or "libimobiledevice" in label or "xcuitest" in label: + hint = "iOS requires macOS + Xcode (drive remote iOS via IPH_APPIUM_URL on a Mac)" + else: + hint = "install via your OS pkg manager" + print(f"{prefix} {label}: SKIP ({hint})") continue if check(): @@ -128,9 +217,33 @@ def run(ios=True, android=True, dry_run=False): continue if cmd is None: - # Special-case: brew itself. + # Either brew itself (macOS), or an unknown Linux distro with no + # known package manager. print(f"{prefix} {label}: MISSING") - print(' Install: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"') + if is_linux(): + print(" No supported package manager detected. Install manually:") + if "adb" in label.lower() or "platform tools" in label.lower(): + print(" - Debian/Ubuntu: sudo apt install android-tools-adb") + print(" - Fedora/RHEL: sudo dnf install android-tools") + print(" - Arch/Manjaro: sudo pacman -S android-tools") + print(" - openSUSE: sudo zypper install android-tools") + print(" - Alpine: sudo apk add android-tools") + elif "node" in label.lower(): + print(" - Debian/Ubuntu: sudo apt install nodejs npm") + print(" - Fedora/RHEL: sudo dnf install nodejs npm") + print(" - Arch/Manjaro: sudo pacman -S nodejs npm") + print(" - openSUSE: sudo zypper install nodejs npm") + print(" - Alpine: sudo apk add nodejs npm") + elif "Xcode" in label: + print(" Install Xcode from the App Store (~10 GB, free):") + print(" 1. Open the App Store, search for 'Xcode', click Get.") + print(" 2. Launch Xcode once and accept the license:") + print(" sudo xcodebuild -license accept") + print(" 3. Point command-line tools at Xcode:") + print(" sudo xcode-select -s /Applications/Xcode.app/Contents/Developer") + print(" Then re-run: mobile-use bootstrap") + else: + print(' Install: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"') rc = 1 continue @@ -161,8 +274,10 @@ def run(ios=True, android=True, dry_run=False): print(" `mobile-use quickstart` (full smoke test)") else: print("bootstrap finished with errors. Re-run after fixing the FAIL lines above.") - if sys.platform == "darwin": + if is_macos(): print("Manual reference: SETUP.md") + elif is_linux(): + print("Manual reference: SETUP.md (see '## Linux setup')") return rc diff --git a/mobile_use/cli.py b/mobile_use/cli.py index 1e926a8..9a7b9cc 100644 --- a/mobile_use/cli.py +++ b/mobile_use/cli.py @@ -54,6 +54,51 @@ def _detect_platform(): return None # nothing connected +def _check_env_for_platform(platform): + """Pre-flight: warn if .env hasn't been initialized for the chosen platform. + + Returns None if OK, otherwise an actionable error message ready to surface. + """ + key = "IPH_UDID" if platform == "ios" else "ANH_UDID" + if os.environ.get(key, "").strip(): + return None + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + env_path = os.path.join(repo_root, ".env") + alt_path = os.path.join(repo_root, "agent-workspace", ".env") + if not os.path.exists(env_path) and not os.path.exists(alt_path): + return ( + f"No .env file found and {key} not set in environment.\n" + f" Fix: run `mobile-use init` (auto-fills from connected device).\n" + f" Or set {key}= manually before running." + ) + return None + + +def _maybe_start_viewer(platform): + """If MOBILE_USE_HEADED=1, spawn the MJPEG viewer + open browser. Returns + the ViewerServer (or None). Caller must call .stop() at end. Failures + log + return None — viewer is a "nice to have", never blocks the command. + """ + if os.environ.get("MOBILE_USE_HEADED") != "1": + return None + try: + from mobile_use.viewer.server import ViewerServer + v = ViewerServer(platform=platform) + v.start() + print(f"[mobile-use] live viewer at {v.url} (--headless to disable)", + file=sys.stderr) + try: + import webbrowser + webbrowser.open(v.url) + except Exception: + pass + return v + except Exception as e: + print(f"[mobile-use] viewer failed to start: {e} (continuing without)", + file=sys.stderr) + return None + + def _run_ios(args): """Delegate to iphone-harness.""" from iphone_harness.admin import ensure_daemon, restart_daemon, run_doctor @@ -71,10 +116,34 @@ def _run_ios(args): if args[0] != "-c" or len(args) < 2: sys.exit('Usage: mobile-use --ios -c "print(active_app())"') - ensure_daemon() + env_err = _check_env_for_platform("ios") + if env_err: + sys.exit(env_err) + + try: + ensure_daemon() + except RuntimeError as e: + sys.exit(f"{e}") + + viewer = _maybe_start_viewer("ios") ns = {k: v for k, v in vars(_helpers).items() if not k.startswith("_")} ns["__builtins__"] = __builtins__ - exec(args[1], ns) + try: + try: + exec(args[1], ns) + except SystemExit: + raise + except SyntaxError as e: + sys.exit(f"Syntax error in your -c script: {e.msg} (line {e.lineno})") + except Exception as e: + # Show the user's script error without the cli.py traceback noise. + import traceback + tb = traceback.format_exc() + sys.stderr.write(tb) + sys.exit(1) + finally: + if viewer is not None: + viewer.stop() def _run_android(args): @@ -94,10 +163,32 @@ def _run_android(args): if args[0] != "-c" or len(args) < 2: sys.exit('Usage: mobile-use --android -c "print(active_app())"') - ensure_daemon() + env_err = _check_env_for_platform("android") + if env_err: + sys.exit(env_err) + + try: + ensure_daemon() + except RuntimeError as e: + sys.exit(f"{e}") + + viewer = _maybe_start_viewer("android") ns = {k: v for k, v in vars(_helpers).items() if not k.startswith("_")} ns["__builtins__"] = __builtins__ - exec(args[1], ns) + try: + try: + exec(args[1], ns) + except SystemExit: + raise + except SyntaxError as e: + sys.exit(f"Syntax error in your -c script: {e.msg} (line {e.lineno})") + except Exception: + import traceback + sys.stderr.write(traceback.format_exc()) + sys.exit(1) + finally: + if viewer is not None: + viewer.stop() def _doctor_both(): @@ -128,31 +219,59 @@ def _doctor_both(): HELP = """mobile-use — direct mobile device control via Appium -Quickstart (first run): - mobile-use bootstrap install Appium + driver + system deps - mobile-use init write .env from connected device - mobile-use quickstart doctor + smoke test (proves it works) +QUICKSTART (first run, three commands): + mobile-use bootstrap [--dry-run] [--ios-only] [--android-only] + install Appium + driver + system deps + mobile-use init [--yes] write .env from connected device + mobile-use quickstart [--ios|--android] + doctor + smoke test (proves it works) -Usage: - mobile-use --ios -c '' run iOS script (iphone-harness) - mobile-use --android -c '' run Android script (android-harness) - mobile-use -c '' auto-detect platform - mobile-use --doctor diagnose all platforms - mobile-use --ios --doctor diagnose iOS only - mobile-use --android --doctor diagnose Android only +RUN SCRIPTS: + mobile-use -c '' auto-detect platform (single device) + mobile-use --ios -c '' force iOS + mobile-use --android -c '' force Android mobile-use agent [--ios|--android] persistent agent loop - mobile-use export-training [FILE] export training data to JSONL - mobile-use training-stats show training data summary - mobile-use --version - -Options: - --name Named daemon instance for multiboxing (multiple devices). - Each name gets its own Appium session and Unix socket. -Auto-detection: when only one device type is connected, --ios/--android -is inferred. When both are connected, you must specify. - -Multiboxing (Python API): +DIAGNOSE: + mobile-use --doctor diagnose all platforms + mobile-use --ios --doctor iOS only + mobile-use --android --doctor Android only + +SETUP & MAINTENANCE: + mobile-use ios sign-wda [--check] re-sign WebDriverAgent (iOS #1 blocker) + mobile-use ios build-wda [--check] build WebDriverAgent test target (iOS first-run) + mobile-use --reload nuke daemon (kills stale state) + mobile-use --ios --reload iOS daemon only + mobile-use --android --reload Android daemon only + +DATA: + mobile-use export-training [FILE] export training data to JSONL + mobile-use training-stats show training data summary + +OPTIONS: + --name Named daemon for multiboxing (per-device socket + session). + --remote-daemon Client-only mode — drive a remote daemon over TCP. + Example: `--remote-daemon tcp://127.0.0.1:8763` + (use `ssh -L 8763:127.0.0.1:8763 ` first). + Lets a Windows or Linux host control iOS via a Mac + running Appium+WebDriverAgent+iphone-harness daemon. + Sets IPH_CONNECT (--ios) or ANH_CONNECT (--android). + --headed Open a live device-screen viewer in your browser + while the command runs (MJPEG at ~6 fps, JPEG q=60). + --headless Explicit opt-out of viewer (default). + --version Show version + +AUTO-DETECT: when exactly one device type is connected, --ios/--android +is inferred. When both connected, you must specify. + +iOS FROM WINDOWS / LINUX (remote Mac bridge): + On the Mac (one time): mobile-use bootstrap --ios-only && mobile-use ios build-wda + On the Mac (each run): IPH_BIND=tcp://127.0.0.1:8763 iphone-harness -c 'pass' + On Windows/Linux: ssh -L 8763:127.0.0.1:8763 mac (in another shell) + On Windows/Linux: mobile-use --ios --remote-daemon tcp://127.0.0.1:8763 -c '...' + Full walkthrough: SETUP.md → "iOS from Windows / Linux" + +MULTIBOXING (Python API): from mobile_use import DevicePool pool = DevicePool() pool.add_ios("iphone1", udid="...") @@ -160,9 +279,13 @@ def _doctor_both(): pool.ensure_all_ready() pool.broadcast(lambda d: d.screenshot()) -Platform-specific CLIs still work: - iphone-harness -c '...' - android-harness -c '...' +PLATFORM-SPECIFIC CLIs (also installed): + iphone-harness -c '...' | --doctor | --reload + android-harness -c '...' | --doctor | --reload + +DOCS: + README.md quickstart + runtime helpers + SETUP.md full per-step setup + troubleshooting tree """ @@ -177,9 +300,11 @@ def main(): print(f"mobile-use {__version__}") return - # Extract platform flag and --name + # Extract platform flag, --name, --remote-daemon, --headed/--headless platform = None instance_name = None + remote_daemon = None + headed = None # None = default (headless), True = headed, False = explicit headless remaining = [] i = 0 while i < len(args): @@ -190,6 +315,13 @@ def main(): elif args[i] == "--name" and i + 1 < len(args): instance_name = args[i + 1] i += 1 + elif args[i] == "--remote-daemon" and i + 1 < len(args): + remote_daemon = args[i + 1] + i += 1 + elif args[i] == "--headed": + headed = True + elif args[i] == "--headless": + headed = False else: remaining.append(args[i]) i += 1 @@ -200,8 +332,31 @@ def main(): if platform == "android" or platform is None: os.environ["ANH_NAME"] = instance_name - # Doctor with no platform → run both - if remaining and remaining[0] == "--doctor" and platform is None: + # --remote-daemon sets IPH_CONNECT / ANH_CONNECT for the chosen platform. + # Validates the URI eagerly so bad input fails fast with a clear error. + if remote_daemon: + from iphone_harness import _ipc as _iph_ipc + try: + _iph_ipc.parse_endpoint(remote_daemon) + except ValueError as e: + sys.exit(f"Invalid --remote-daemon URI: {e}") + if platform == "ios" or platform is None: + os.environ["IPH_CONNECT"] = remote_daemon + if platform == "android" or platform is None: + os.environ["ANH_CONNECT"] = remote_daemon + + # --headed / --headless: tri-state. None = leave MOBILE_USE_HEADED untouched + # (default off). True/False = explicit user choice; pass via mutable env so + # subprocess-spawn paths (agent loop, etc) inherit, but restore on exit so + # in-process pytest runs don't leak state into sibling tests. + _prior_headed = os.environ.get("MOBILE_USE_HEADED") + if headed is True: + os.environ["MOBILE_USE_HEADED"] = "1" + elif headed is False: + os.environ["MOBILE_USE_HEADED"] = "0" + + # Doctor with no platform → run both. Accept both `--doctor` (flag) and `doctor` (subcommand). + if remaining and remaining[0] in {"--doctor", "doctor"} and platform is None: sys.exit(_doctor_both()) # New UX subcommands (no platform required) @@ -217,6 +372,25 @@ def main(): from . import quickstart sys.exit(quickstart.main(remaining[1:], platform=platform)) + # ios — iOS-specific subcommands. + if remaining and remaining[0] == "ios": + if len(remaining) < 2 or remaining[1] in {"-h", "--help"}: + print( + "mobile-use ios — iOS-specific subcommands:\n\n" + " sign-wda [--check] Sign WebDriverAgent in Xcode (the #1 setup blocker).\n" + " --check exits 0 if already signed, 1 otherwise.\n" + " build-wda [--check] Build the WebDriverAgent test target (first-run setup).\n" + " --check exits 0 if already built, 1 otherwise.\n" + ) + sys.exit(0 if len(remaining) >= 2 else 2) + if remaining[1] == "sign-wda": + from . import ios_wda + sys.exit(ios_wda.main(remaining[2:])) + if remaining[1] == "build-wda": + from . import ios_wda + sys.exit(ios_wda.build_main(remaining[2:])) + sys.exit(f"Unknown `mobile-use ios` action: {remaining[1]!r}. Try `mobile-use ios --help`.") + # Training data commands if remaining and remaining[0] == "export-training": from .collector import export_training_data @@ -257,10 +431,19 @@ def main(): "\nRun `mobile-use --doctor` to check what's connected." ) - if platform == "ios": - _run_ios(remaining) - elif platform == "android": - _run_android(remaining) + try: + if platform == "ios": + _run_ios(remaining) + elif platform == "android": + _run_android(remaining) + finally: + # Restore prior MOBILE_USE_HEADED so in-process pytest doesn't leak + # state between tests that call cli.main() with different --headed. + if headed is not None: + if _prior_headed is None: + os.environ.pop("MOBILE_USE_HEADED", None) + else: + os.environ["MOBILE_USE_HEADED"] = _prior_headed if __name__ == "__main__": diff --git a/mobile_use/ios_wda.py b/mobile_use/ios_wda.py new file mode 100644 index 0000000..0fdf8f5 --- /dev/null +++ b/mobile_use/ios_wda.py @@ -0,0 +1,332 @@ +"""WDA signing helper — `mobile-use ios sign-wda`. + +WebDriverAgent signing is the #1 setup blocker for iOS. Apple requires every +developer to re-sign + re-trust WDA every week (free accounts) or year (paid). +This module: + - detects WDA signing state (signed / not-signed / expired / unknown) + - opens the WebDriverAgent Xcode project for manual signing + - prints concrete step-by-step instructions + - re-verifies after user reports done + +No interaction with the device requires Appium running — checks are local +(filesystem + idevice* / xcrun) + provisioning profile parsing. +""" +import os +import plistlib +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + + +# Where appium-xcuitest-driver installs WebDriverAgent's xcodeproj. +WDA_PROJECT_CANDIDATES = ( + "~/.appium/node_modules/appium-xcuitest-driver/node_modules/appium-webdriveragent/WebDriverAgent.xcodeproj", + "/usr/local/lib/node_modules/appium-xcuitest-driver/node_modules/appium-webdriveragent/WebDriverAgent.xcodeproj", + "/opt/homebrew/lib/node_modules/appium-xcuitest-driver/node_modules/appium-webdriveragent/WebDriverAgent.xcodeproj", +) + +# Provisioning profiles live here on macOS. +PROVISIONING_DIR = Path("~/Library/MobileDevice/Provisioning Profiles").expanduser() + +# Bundle ID prefix WDA uses by default. User-configurable via IPH_WDA_BUNDLE_ID. +DEFAULT_WDA_BUNDLE = "com.facebook.WebDriverAgentRunner.xctrunner" + + +def find_wda_project(): + """Return Path to the WebDriverAgent.xcodeproj, or None if not installed.""" + for p in WDA_PROJECT_CANDIDATES: + path = Path(p).expanduser() + if path.exists(): + return path + return None + + +def _provisioning_profiles(): + """Yield (path, plist_dict) for every installed provisioning profile. + + .mobileprovision files are CMS-signed plists. `security cms -D -i ` + extracts the inner plist as XML. + """ + if not PROVISIONING_DIR.exists(): + return + for p in sorted(PROVISIONING_DIR.glob("*.mobileprovision")): + try: + out = subprocess.check_output( + ["security", "cms", "-D", "-i", str(p)], + timeout=5.0, stderr=subprocess.DEVNULL, + ) + yield p, plistlib.loads(out) + except Exception: + continue + + +def _matches_wda(profile, wda_bundle): + """True if this provisioning profile is for the WDA bundle ID.""" + entitlements = profile.get("Entitlements", {}) or {} + app_id = entitlements.get("application-identifier", "") or "" + # app_id format: TEAMID.com.facebook.WebDriverAgentRunner.xctrunner + return app_id.endswith(wda_bundle) or wda_bundle in app_id + + +def check_wda_signing(wda_bundle=None): + """Return (state, details) where state ∈ {signed, expired, not_signed, unknown}. + + State semantics: + signed — valid provisioning profile present, not expired + expired — provisioning profile found but ExpirationDate ≤ now + not_signed — no provisioning profile matches the WDA bundle ID + unknown — checks failed (security tool missing, permissions, etc.) + """ + wda_bundle = wda_bundle or os.environ.get("IPH_WDA_BUNDLE_ID") or DEFAULT_WDA_BUNDLE + + if sys.platform != "darwin": + return "unknown", "not on macOS — signing only valid on Mac" + + if not shutil.which("security"): + return "unknown", "`security` tool missing (should be on macOS by default)" + + matching = [] + for path, profile in _provisioning_profiles(): + if not _matches_wda(profile, wda_bundle): + continue + expiry = profile.get("ExpirationDate") + team_name = profile.get("TeamName") or "(unknown team)" + matching.append((path, expiry, team_name)) + + if not matching: + return "not_signed", f"no provisioning profile for {wda_bundle}" + + # Pick the one with the latest expiry. + matching.sort(key=lambda m: m[1] or datetime.min.replace(tzinfo=timezone.utc), reverse=True) + path, expiry, team_name = matching[0] + + if expiry is None: + return "unknown", f"profile found ({path.name}) but no ExpirationDate" + + now = datetime.now(timezone.utc) + if hasattr(expiry, "tzinfo") and expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=timezone.utc) + + if expiry <= now: + days_ago = (now - expiry).days + return "expired", f"profile expired {days_ago}d ago ({team_name})" + + days_left = (expiry - now).days + return "signed", f"signed ({team_name}, expires in {days_left}d)" + + +def open_in_xcode(path): + """Open the WDA project in Xcode (macOS-only). Returns True on success.""" + if sys.platform != "darwin": + return False + try: + subprocess.check_call(["open", "-a", "Xcode", str(path)], timeout=10.0) + return True + except Exception: + return False + + +SIGNING_STEPS = """\ +WebDriverAgent signing — 6 steps: + + 1. In Xcode (just opened): select the WebDriverAgent project in the file tree. + + 2. Select the `WebDriverAgentRunner` target (in the targets list at the top). + + 3. Open the "Signing & Capabilities" tab. + + 4. Tick "Automatically manage signing". + + 5. Pick your team in the Team dropdown. + • Free Apple ID: re-do this weekly (profile expires every 7 days). + • Paid Apple Developer Program: re-do this yearly. + + 6. Build the target (Cmd+B). Xcode will provision + sign WDA. + +After build succeeds: + • On iPhone: Settings → General → VPN & Device Management + • Tap your developer profile → "Trust ..." + • Then re-run: mobile-use ios sign-wda --check + +If build fails with a bundle-id collision: + • Change the bundle ID under "Signing & Capabilities" (e.g. com.you.WDA.xctrunner) + • Then export it: IPH_WDA_BUNDLE_ID=com.you.WDA.xctrunner + • Add the same line to your .env file +""" + + +def check_wda_built(): + """Return (built, detail) — True if a WebDriverAgentRunner-Runner.app is in DerivedData. + + Xcode builds land in ~/Library/Developer/Xcode/DerivedData/WebDriverAgent-*/Build/Products/. + We scan for the .app to know whether the user has built the test target at least once. + """ + if sys.platform != "darwin": + return False, "not on macOS" + derived = Path("~/Library/Developer/Xcode/DerivedData").expanduser() + if not derived.exists(): + return False, "no DerivedData dir (Xcode never run)" + candidates = list(derived.glob("WebDriverAgent-*/Build/Products/*/WebDriverAgentRunner-Runner.app")) + if not candidates: + return False, "no WebDriverAgentRunner-Runner.app under DerivedData" + latest = max(candidates, key=lambda p: p.stat().st_mtime) + age_days = (datetime.now().timestamp() - latest.stat().st_mtime) / 86400 + return True, f"built at {latest.parent.name} ({age_days:.1f}d ago)" + + +def _team_id_arg(): + """Return DEVELOPMENT_TEAM=... if IPH_XCODE_ORG_ID is set, else empty list.""" + tid = os.environ.get("IPH_XCODE_ORG_ID", "").strip() + return [f"DEVELOPMENT_TEAM={tid}"] if tid else [] + + +def _bundle_id_arg(): + """Return PRODUCT_BUNDLE_IDENTIFIER=... if IPH_WDA_BUNDLE_ID is set.""" + bid = os.environ.get("IPH_WDA_BUNDLE_ID", "").strip() + return [f"PRODUCT_BUNDLE_IDENTIFIER={bid}"] if bid else [] + + +def build_wda(udid=None, timeout=600): + """Build the WebDriverAgent test target. Returns (rc, output). + + Uses `xcodebuild build-for-testing` so we don't need a connected device — + we just want the .app + .xctestrun to exist for Appium to install at runtime. + If a UDID is supplied, target that specific device; otherwise build for any iOS device. + """ + project = find_wda_project() + if project is None: + return 1, "WebDriverAgent project not found — run `appium driver install xcuitest` first." + + destination = f"id={udid}" if udid else "generic/platform=iOS" + cmd = [ + "xcodebuild", + "build-for-testing", + "-project", str(project), + "-scheme", "WebDriverAgentRunner", + "-destination", destination, + "-allowProvisioningUpdates", + "CODE_SIGNING_ALLOWED=YES", + *_team_id_arg(), + *_bundle_id_arg(), + ] + try: + proc = subprocess.run( + cmd, timeout=timeout, capture_output=True, text=True, + ) + return proc.returncode, (proc.stdout or "") + (proc.stderr or "") + except subprocess.TimeoutExpired: + return 124, f"xcodebuild timed out after {timeout}s" + except FileNotFoundError: + return 1, "xcodebuild not found — install Xcode (not just CLT)" + + +def build_main(argv): + """Entry: mobile-use ios build-wda [--check] + + Build the WebDriverAgent test target so Appium can install it on the device + on first run. This is the manual step in SETUP.md Part A4 — automated. + """ + if any(a in {"-h", "--help"} for a in argv): + print( + "mobile-use ios build-wda [--check] [--udid UDID]\n\n" + "Build the WebDriverAgent test target. Required once before the first\n" + "Appium session can install WDA on the iPhone. This automates SETUP.md\n" + "Part A4 (the manual Xcode project open + Cmd+U).\n\n" + "Prerequisites:\n" + " - Xcode installed (App Store, ~10 GB)\n" + " - WDA signed: mobile-use ios sign-wda (or sign-wda --check)\n" + " - IPH_XCODE_ORG_ID set (your Apple Team ID; from .env)\n\n" + "Options:\n" + " --check Exit 0 if WDA is already built, 1 otherwise.\n" + " --udid UDID Build targeted at a specific device UDID.\n" + ) + return 0 + + check_only = "--check" in argv + udid = None + for i, a in enumerate(argv): + if a == "--udid" and i + 1 < len(argv): + udid = argv[i + 1] + + built, detail = check_wda_built() + print(f"WDA build: {'built' if built else 'not built'} ({detail})") + + if check_only: + return 0 if built else 1 + + if built: + print("Nothing to do — WDA is already built.") + print("Force a rebuild: rm -rf ~/Library/Developer/Xcode/DerivedData/WebDriverAgent-*") + return 0 + + sign_state, sign_detail = check_wda_signing() + if sign_state != "signed": + print(f"\nWDA is not signed ({sign_state}: {sign_detail}).") + print("Run `mobile-use ios sign-wda` first, then re-run this command.") + return 1 + + udid = udid or os.environ.get("IPH_UDID", "").strip() or None + target = f"device {udid}" if udid else "generic iOS" + print(f"\nBuilding WebDriverAgent for {target} (this can take several minutes)...") + + rc, output = build_wda(udid=udid) + if rc == 0: + print("Build succeeded.") + built2, detail2 = check_wda_built() + print(f"Verify: {detail2}") + return 0 + + print(f"\nBuild FAILED (rc={rc}). Last 40 lines of xcodebuild output:") + for line in output.splitlines()[-40:]: + print(f" {line}") + print( + "\nCommon fixes:\n" + " - Bundle ID collision → change IPH_WDA_BUNDLE_ID (see SETUP.md A4)\n" + " - Provisioning failed → re-open Xcode, retry sign-wda\n" + " - Code-signing identity missing → Xcode → Settings → Accounts → add Apple ID\n" + ) + return rc + + +def main(argv): + """Entry: mobile-use ios sign-wda [--check]""" + if any(a in {"-h", "--help"} for a in argv): + print( + "mobile-use ios sign-wda [--check]\n\n" + "WebDriverAgent must be signed with your Apple Team ID before Appium\n" + "can run on a physical iPhone. This command opens the WDA Xcode project\n" + "and prints the 6-step signing flow. Free Apple accounts re-sign weekly.\n\n" + "Options:\n" + " --check Print signing state and exit 0 if signed, 1 otherwise.\n" + " Without --check, opens Xcode and walks through signing.\n" + ) + return 0 + check_only = "--check" in argv + + state, details = check_wda_signing() + print(f"WDA signing: {state} ({details})") + + if check_only: + # 0 = signed (ready); 1 = needs action. + return 0 if state == "signed" else 1 + + if state == "signed": + print("Nothing to do — WDA is signed.") + return 0 + + project = find_wda_project() + if project is None: + print("\nWebDriverAgent project not found. Install with:") + print(" appium driver install xcuitest") + return 1 + + print(f"\nOpening {project} in Xcode...") + if not open_in_xcode(project): + print(f" Failed to open. Run manually: open -a Xcode \"{project}\"") + return 1 + + print() + print(SIGNING_STEPS) + return 0 diff --git a/mobile_use/quickstart.py b/mobile_use/quickstart.py index 28d7cf9..4c4b860 100644 --- a/mobile_use/quickstart.py +++ b/mobile_use/quickstart.py @@ -18,6 +18,71 @@ import shutil import subprocess import sys +import urllib.error +import urllib.request + +APPIUM_URL = os.environ.get("IPH_APPIUM_URL") or os.environ.get("ANH_APPIUM_URL") or "http://127.0.0.1:4723" + + +def appium_reachable(url=None, timeout=1.5): + """True if the Appium server responds on /status. False on any error.""" + url = (url or APPIUM_URL).rstrip("/") + "/status" + try: + with urllib.request.urlopen(url, timeout=timeout) as r: + return r.status == 200 + except (urllib.error.URLError, ConnectionError, OSError): + return False + except Exception: + return False + + +def run_appium_phase(*, autostart=False): + """Preflight: check Appium server is up at 4723. Returns (ok, msg). + + If `autostart=True` and Appium isn't reachable but the CLI is installed, + spawn `appium --base-path /` detached and re-check. Otherwise print the + exact command the user needs to run. + """ + if appium_reachable(): + return True, f"Appium server reachable at {APPIUM_URL}" + + appium_cli = shutil.which("appium") + if appium_cli is None: + return False, ( + "Appium server not reachable AND `appium` CLI not installed.\n" + " Fix: run `mobile-use bootstrap` to install Appium + drivers." + ) + + if not autostart: + return False, ( + f"Appium server not running on {APPIUM_URL}.\n" + " Fix: open a separate terminal and run:\n" + " appium --base-path /\n" + " Or re-run quickstart with --autostart-appium to spawn it for you." + ) + + # Try to start it. Detach so we don't block on its lifetime. + log_path = os.path.join("/tmp", "mobile-use-appium.log") + try: + with open(log_path, "ab") as log: + subprocess.Popen( + [appium_cli, "--base-path", "/"], + stdout=log, stderr=log, stdin=subprocess.DEVNULL, + start_new_session=True, + ) + except Exception as e: + return False, f"failed to start Appium: {e}" + + # Wait up to ~6s for it to come up. + import time + for _ in range(12): + time.sleep(0.5) + if appium_reachable(): + return True, f"Appium server started (log: {log_path})" + return False, ( + f"Spawned Appium but it didn't become reachable in 6s.\n" + f" Check {log_path} for errors." + ) def _detect_platform(): @@ -98,6 +163,8 @@ def main(argv=None, *, platform=None): p.add_argument("--android", action="store_const", const="android", dest="platform") p.add_argument("--skip-doctor", action="store_true", help="Jump straight to the smoke test.") + p.add_argument("--autostart-appium", action="store_true", + help="If Appium server is down, spawn it in the background.") args = p.parse_args(argv) platform = platform or args.platform or _detect_platform() @@ -111,6 +178,13 @@ def main(argv=None, *, platform=None): print(f"mobile-use quickstart ({platform})") print("=" * 60) + print(f"[preflight] Appium server on {APPIUM_URL}") + ok, msg = run_appium_phase(autostart=args.autostart_appium) + print(f" {msg}") + if not ok: + print(f"\n[abort] {msg}") + return 1 + if not args.skip_doctor: ok, msg = run_doctor_phase(platform) if not ok: diff --git a/mobile_use/record_replay.py b/mobile_use/record_replay.py new file mode 100644 index 0000000..4141161 --- /dev/null +++ b/mobile_use/record_replay.py @@ -0,0 +1,225 @@ +"""Record-replay — capture a sequence of taps/swipes/typing as a replayable script. + +Usage: + + from mobile_use import record_replay + import iphone_harness.helpers as h + + record_replay.start_recording('test.py', helpers=h) + h.tap_at_xy(100, 200) + h.type_text('hello') + h.swipe(0, 500, 0, 100) + record_replay.stop_recording() + # → test.py is a runnable Python file that replays the same calls. + + record_replay.replay('test.py', helpers=h) + # → re-executes the recorded sequence. + +Implementation: at start_recording, we wrap each helper function in the supplied +module with a recording trampoline that journals the call and forwards to the +original. stop_recording restores originals and writes the journal to a Python +script. +""" +from __future__ import annotations + +import functools +import json +import time +from pathlib import Path +from typing import Any + + +# Default set of helpers to record. Anything not listed is left alone. +RECORDED_HELPERS = ( + "tap_at_xy", "tap", + "double_tap", "long_press", + "swipe", "scroll", "scroll_by", + "type_text", + "press_back", "press_home", "press_recents", + "open_notifications", "open_control_center", "close_control_center", +) + + +_state: dict[str, Any] = { + "recording": False, + "journal": [], + "output_path": None, + "helpers_module": None, + "originals": {}, + "started_at": None, +} + + +def is_recording() -> bool: + return _state["recording"] + + +def _make_recorder(name: str, original): + @functools.wraps(original) + def recorded(*args, **kwargs): + _state["journal"].append({ + "t": round(time.time() - _state["started_at"], 3), + "fn": name, + "args": list(args), + "kwargs": dict(kwargs), + }) + return original(*args, **kwargs) + return recorded + + +def start_recording(output_path: str, helpers, fn_names: tuple[str, ...] | None = None): + """Begin recording calls to the named helpers. + + Args: + output_path: where to write the .py script when stop_recording is called. + helpers: the helpers module to wrap (iphone_harness.helpers or android_harness.helpers). + fn_names: which helpers to record; defaults to RECORDED_HELPERS. + """ + if _state["recording"]: + raise RuntimeError("record_replay: already recording — call stop_recording() first.") + + if not hasattr(helpers, "__name__"): + raise TypeError( + f"record_replay: helpers must be a module-like object with __name__, " + f"got {type(helpers).__name__}" + ) + + _state["recording"] = True + _state["journal"] = [] + _state["output_path"] = output_path + _state["helpers_module"] = helpers + _state["originals"] = {} + _state["started_at"] = time.time() + + names = fn_names or RECORDED_HELPERS + try: + for name in names: + original = getattr(helpers, name, None) + if original is None or not callable(original): + continue + _state["originals"][name] = original + setattr(helpers, name, _make_recorder(name, original)) + except Exception: + # Partial wrap → restore everything and clear state + for n, orig in _state["originals"].items(): + try: + setattr(helpers, n, orig) + except Exception: + pass + _state["recording"] = False + _state["journal"] = [] + _state["originals"] = {} + raise + + +def stop_recording() -> str: + """End recording, restore helpers, write the script. Returns the path.""" + if not _state["recording"]: + raise RuntimeError("record_replay: not recording.") + + helpers = _state["helpers_module"] + for name, original in _state["originals"].items(): + setattr(helpers, name, original) + + script = _generate_script(_state["journal"], helpers.__name__) + out = _state["output_path"] + Path(out).write_text(script) + + _state["recording"] = False + _state["journal"] = [] + _state["originals"] = {} + return out + + +def _format_arg(v) -> str: + if isinstance(v, str): + return json.dumps(v) + if isinstance(v, (int, float, bool, type(None))): + return repr(v) + if isinstance(v, (list, tuple, dict)): + try: + return json.dumps(v) + except (TypeError, ValueError): + return repr(v) + return repr(v) + + +def _format_call(entry: dict) -> str: + args = ", ".join(_format_arg(a) for a in entry["args"]) + kwargs = ", ".join(f"{k}={_format_arg(v)}" for k, v in entry["kwargs"].items()) + body = ", ".join(p for p in (args, kwargs) if p) + return f"h.{entry['fn']}({body})" + + +def _generate_script(journal: list[dict], helpers_module: str) -> str: + lines = [ + '"""Recorded with mobile_use.record_replay. Edit freely.', + '', + f'Source module: {helpers_module}', + f'Calls captured: {len(journal)}', + '"""', + f'import {helpers_module} as h', + 'import time', + '', + ] + prev_t = 0.0 + for entry in journal: + gap = entry["t"] - prev_t + if gap > 0.05: # only inject sleeps for noticeable pauses + lines.append(f"time.sleep({round(gap, 2)})") + lines.append(_format_call(entry)) + prev_t = entry["t"] + lines.append('') + return "\n".join(lines) + + +class recording: + """Context manager around start_recording/stop_recording. + + Guarantees stop_recording is called even if the body raises, so helpers + are always restored. Returns the output path on __exit__. + + with recording("flow.py", helpers=h): + h.tap_at_xy(100, 200) + h.type_text("hello") + """ + def __init__(self, output_path, helpers, fn_names=None): + self.output_path = output_path + self.helpers = helpers + self.fn_names = fn_names + + def __enter__(self): + start_recording(self.output_path, self.helpers, self.fn_names) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if _state["recording"]: + try: + stop_recording() + except Exception: + # Best-effort: ensure helpers restored even if write fails + for n, orig in _state["originals"].items(): + try: + setattr(_state["helpers_module"], n, orig) + except Exception: + pass + _state["recording"] = False + _state["journal"] = [] + _state["originals"] = {} + return False # don't swallow exceptions + + +def replay(script_path: str, helpers=None): + """Execute a previously-recorded script. + + Just runs the .py file with the helpers module in scope. The script imports + its own helpers, so `helpers=` is optional unless you want to inject mocks. + """ + path = Path(script_path) + if not path.exists(): + raise FileNotFoundError(f"replay script not found: {script_path}") + code = path.read_text() + ns: dict[str, Any] = {"__name__": "__replay__"} + if helpers is not None: + ns["h"] = helpers + exec(compile(code, str(path), "exec"), ns) diff --git a/mobile_use/setup_env.py b/mobile_use/setup_env.py index 043205b..16fc646 100644 --- a/mobile_use/setup_env.py +++ b/mobile_use/setup_env.py @@ -124,8 +124,18 @@ def fill(key, value, *, only_if_blank=True): if yes: fill("IPH_XCODE_ORG_ID", defaults.get("IPH_XCODE_ORG_ID", "")) else: + if sys.stdin.isatty(): + print() + print("IPH_XCODE_ORG_ID — your Apple Team ID (10 chars, e.g. ABCDE12345)") + print(" How to find it:") + print(" 1. Open Keychain Access (Spotlight: Keychain)") + print(" 2. Login keychain → My Certificates") + print(" 3. Expand 'Apple Development: ' → double-click cert") + print(" 4. Get Info → 'Organizational Unit' field is the 10-char Team ID") + print(" Or in Xcode: Settings → Accounts → select Apple ID → Manage Certificates") + print(" Full walkthrough: SETUP.md Part A3") fill("IPH_XCODE_ORG_ID", - _prompt("IPH_XCODE_ORG_ID (10-char Apple Team ID — Keychain → cert → Get Info)", + _prompt("IPH_XCODE_ORG_ID", default="")) if not has_real_value(out, "IPH_WDA_BUNDLE_ID"): diff --git a/mobile_use/viewer/__init__.py b/mobile_use/viewer/__init__.py new file mode 100644 index 0000000..dc84500 --- /dev/null +++ b/mobile_use/viewer/__init__.py @@ -0,0 +1,10 @@ +"""Live device-screen viewer — powers `mobile-use --headed`. + +Serves an MJPEG stream of the current device screen by pulling frames from +the iphone-harness or android-harness daemon and re-emitting them as +multipart/x-mixed-replace over HTTP. Browser tabs render it as a live image. + +Single-consumer for v1 (one viewer at a time). Viewer is read-only — it never +sends input to the device. Use the agent loop / `-c` for that. +""" +from .server import ViewerServer # noqa: F401 diff --git a/mobile_use/viewer/server.py b/mobile_use/viewer/server.py new file mode 100644 index 0000000..166e3ce --- /dev/null +++ b/mobile_use/viewer/server.py @@ -0,0 +1,248 @@ +"""HTTP MJPEG viewer — stdlib-only (no extra deps). + +Routes: + GET / → static index.html (embedded; one file ships) + GET /stream → multipart/x-mixed-replace JPEG stream pulled from daemon + GET /still → single JPEG snapshot (cheap; for embedding in test asserts) + GET /healthz → 200 OK if frame loop is alive on the daemon side + +Run flow (synchronous owner — caller drives lifecycle): + viewer = ViewerServer(platform="ios") # or "android" + viewer.start() # binds, threads up + print(viewer.url) # http://127.0.0.1:/ + ...do other work... + viewer.stop() # joins thread, stops stream +""" +import base64 +import http.server +import socket +import socketserver +import threading +import time +from pathlib import Path + + +_INDEX_HTML = b""" + + + +mobile-use viewer + + + +
+ mobile-use — live device screen + loading... +
+
+ device screen +
+ + + +""" + + +def _load_helpers(platform): + """Lazy-import the right harness so the viewer module itself stays light.""" + if platform == "ios": + from iphone_harness import helpers + return helpers + if platform == "android": + from android_harness import helpers + return helpers + raise ValueError(f"viewer: unknown platform {platform!r} (expected 'ios' or 'android')") + + +def _free_port(): + """Ask the kernel for an unused TCP port on loopback.""" + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + finally: + s.close() + + +class ViewerServer: + """Pull frames from the device daemon and serve them over HTTP MJPEG. + + Lifecycle is explicit: start() spawns a daemon thread, stop() joins. The + server stops the daemon-side stream loop on close so we don't leak the + Appium-screenshot RPC after the viewer goes away. + """ + + def __init__(self, platform, port=None, fps=6, quality=60, max_dim=800, + host="127.0.0.1"): + self.platform = platform + self.port = port if port is not None else _free_port() + self.host = host + self.fps = fps + self.quality = quality + self.max_dim = max_dim + self._helpers = _load_helpers(platform) + self._server = None + self._thread = None + self._stopped = False + + @property + def url(self): + return f"http://{self.host}:{self.port}/" + + def start(self): + """Tell the daemon to start streaming + spawn the HTTP server thread.""" + self._helpers.screen_stream_start( + fps=self.fps, quality=self.quality, max_dim=self.max_dim, + ) + handler_cls = _make_handler(self._helpers, self.platform) + self._server = _ThreadedHTTPServer((self.host, self.port), handler_cls) + self._thread = threading.Thread( + target=self._server.serve_forever, daemon=True, name="viewer-http", + ) + self._thread.start() + + def stop(self): + """Stop the HTTP server and ask the daemon to halt the capture loop.""" + if self._stopped: + return + self._stopped = True + if self._server is not None: + self._server.shutdown() + self._server.server_close() + try: + self._helpers.screen_stream_stop() + except Exception: + pass + if self._thread is not None: + self._thread.join(timeout=2.0) + + def __enter__(self): + self.start(); return self + + def __exit__(self, *exc): + self.stop() + + +class _ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + """One worker thread per request — MJPEG streams hold the connection open, + so single-threaded HTTPServer would block /healthz polls behind /stream.""" + daemon_threads = True + allow_reuse_address = True + + +def _make_handler(helpers, platform): + """Build a request handler class closed over the harness helpers module.""" + + class _Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, fmt, *args): # silence default access log + pass + + def do_GET(self): + if self.path in ("/", "/index.html"): + self._serve_index() + elif self.path == "/stream": + self._serve_stream() + elif self.path == "/still": + self._serve_still() + elif self.path == "/healthz": + self._serve_health() + else: + self.send_error(404, "not found") + + def _serve_index(self): + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(_INDEX_HTML))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(_INDEX_HTML) + + def _serve_still(self): + r = helpers.screen_stream_frame() + if not r.get("ready"): + self.send_error(503, "stream not ready") + return + jpeg = base64.b64decode(r["jpeg_b64"]) + self.send_response(200) + self.send_header("Content-Type", "image/jpeg") + self.send_header("Content-Length", str(len(jpeg))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(jpeg) + + def _serve_health(self): + r = helpers.screen_stream_frame() + import json as _json + body = _json.dumps({ + "platform": platform, + "running": bool(r.get("ready")), + "frame_no": int(r.get("frame_no", 0)), + "fps": float(r.get("fps", 0)), + }).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _serve_stream(self): + boundary = "mobile-use-frame" + self.send_response(200) + self.send_header( + "Content-Type", + f"multipart/x-mixed-replace; boundary={boundary}", + ) + self.send_header("Cache-Control", "no-store") + self.end_headers() + last_no = -1 + try: + while True: + r = helpers.screen_stream_frame() + if r.get("ready") and r.get("frame_no", 0) != last_no: + last_no = r["frame_no"] + jpeg = base64.b64decode(r["jpeg_b64"]) + self.wfile.write(b"--" + boundary.encode() + b"\r\n") + self.wfile.write(b"Content-Type: image/jpeg\r\n") + self.wfile.write( + f"Content-Length: {len(jpeg)}\r\n\r\n".encode() + ) + self.wfile.write(jpeg) + self.wfile.write(b"\r\n") + try: + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + return + # Poll twice the configured fps so we never miss a frame + # without spinning. fps is best-effort; 6fps → 12 polls/s. + fps = max(1.0, float(r.get("fps", 6.0))) + time.sleep(1.0 / (fps * 2.0)) + except (BrokenPipeError, ConnectionResetError): + return # client closed tab — clean shutdown + + return _Handler diff --git a/tests/_mock_android_daemon.py b/tests/_mock_android_daemon.py new file mode 100644 index 0000000..93a3773 --- /dev/null +++ b/tests/_mock_android_daemon.py @@ -0,0 +1,154 @@ +"""Test-only mock android-harness daemon. Same contract as the real one minus Appium.""" +import asyncio +import json +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from android_harness import _ipc as ipc + + +NAME = os.environ.get("ANH_NAME", "default") +LOG = str(ipc.log_path(NAME)) +PID = str(ipc.pid_path(NAME)) + + +class MockDaemon: + def __init__(self): + self.stop = None + self._stream_running = False + self._stream_frame_no = 0 + self._stream_fps = 6.0 + self._stream_quality = 60 + + async def handle(self, req): + meta = req.get("meta") + if meta == "ping": + return {"pong": True, "pid": os.getpid()} + if meta == "shutdown": + self.stop.set() + return {"ok": True} + + method = req.get("method") + if not method: + return {"error": "missing method"} + if method == "appium": + return {"result": {"packageName": "com.android.launcher"}} + if method == "screenshot": + return {"result": {"path": "/tmp/anh-mock-shot.png", "bytes": 0}} + if method == "window_size": + return {"result": {"width": 1080, "height": 1920}} + if method == "page_source": + return {"result": ""} + if method == "click_element": + return {"result": {"matched": 1}} + if method == "send_keys": + return {"result": {"sent": req.get("params", {}).get("keys", ""), "matched": 1}} + if method == "set_value": + return {"result": {"set": req.get("params", {}).get("value", ""), "matched": 1}} + if method == "active_app": + return {"result": {"packageName": "com.android.launcher"}} + # Screen stream — synthetic JPEG stub. + if method == "screen_stream_start": + params = req.get("params") or {} + self._stream_fps = float(params.get("fps", 6)) + self._stream_quality = int(params.get("quality", 60)) + already = self._stream_running + self._stream_running = True + return {"result": { + "running": True, + "started": not already, + "updated": already, + "fps": self._stream_fps, + "quality": self._stream_quality, + "max_dim": int(params.get("max_dim", 800)), + }} + if method == "screen_stream_frame": + if not self._stream_running: + return {"result": {"ready": False, "frame_no": 0}} + self._stream_frame_no += 1 + import base64 + jpeg_stub = bytes.fromhex( + "ffd8ffe000104a46494600010100000100010000ffdb004300080606070605" + "08070707090908" + ) + return {"result": { + "ready": True, + "frame_no": self._stream_frame_no, + "jpeg_b64": base64.b64encode(jpeg_stub).decode("ascii"), + "fps": self._stream_fps, + "quality": self._stream_quality, + }} + if method == "screen_stream_stop": + was = self._stream_running + self._stream_running = False + self._stream_frame_no = 0 + return {"result": {"running": False, "stopped": was}} + return {"error": f"mock: unknown method {method!r}"} + + +async def serve(d): + async def handler(reader, writer): + try: + line = await reader.readline() + if not line: + return + try: + req = json.loads(line) + except json.JSONDecodeError as e: + writer.write((json.dumps({"error": f"bad json: {e}"}) + "\n").encode()) + await writer.drain() + return + resp = await d.handle(req) + writer.write((json.dumps(resp, default=str) + "\n").encode()) + await writer.drain() + except Exception as e: + try: + writer.write((json.dumps({"error": str(e)}) + "\n").encode()) + await writer.drain() + except Exception: + pass + finally: + writer.close() + + serve_task = asyncio.create_task(ipc.serve(NAME, handler)) + stop_task = asyncio.create_task(d.stop.wait()) + await asyncio.sleep(0.05) + try: + await asyncio.wait({serve_task, stop_task}, return_when=asyncio.FIRST_COMPLETED) + finally: + for t in (serve_task, stop_task): + t.cancel() + try: + await t + except (asyncio.CancelledError, Exception): + pass + ipc.cleanup_endpoint(NAME) + + +async def _main(): + d = MockDaemon() + d.stop = asyncio.Event() + await serve(d) + + +def already_running(): + return ipc.ping(NAME, timeout=0.5) + + +if __name__ == "__main__": + if already_running(): + print(f"mock daemon already running for {NAME}", file=sys.stderr) + sys.exit(0) + open(LOG, "w").close() + open(PID, "w").write(str(os.getpid())) + try: + asyncio.run(_main()) + except KeyboardInterrupt: + pass + finally: + try: + os.unlink(PID) + except FileNotFoundError: + pass diff --git a/tests/_mock_iphone_daemon.py b/tests/_mock_iphone_daemon.py new file mode 100644 index 0000000..994f886 --- /dev/null +++ b/tests/_mock_iphone_daemon.py @@ -0,0 +1,177 @@ +"""Test-only mock iphone-harness daemon — IPC plumbing without Appium/device. + +Run as: `python -m tests._mock_iphone_daemon` with IPH_NAME set in env. +Mirrors iphone_harness.daemon's IPC contract for ping/shutdown/method dispatch. +""" +import asyncio +import json +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from iphone_harness import _ipc as ipc + + +NAME = os.environ.get("IPH_NAME", "default") +LOG = str(ipc.log_path(NAME)) +PID = str(ipc.pid_path(NAME)) + + +class MockDaemon: + def __init__(self): + self.stop = None + self.fail_appium = os.environ.get("MOCK_FAIL_APPIUM") == "1" + self._stream_running = False + self._stream_frame_no = 0 + self._stream_fps = 6.0 + self._stream_quality = 60 + + async def handle(self, req): + meta = req.get("meta") + if meta == "ping": + return {"pong": True, "pid": os.getpid()} + if meta == "shutdown": + self.stop.set() + return {"ok": True} + if meta == "session": + return {"session_id": "mock-session"} + + method = req.get("method") + if not method: + return {"error": "missing method"} + + if self.fail_appium and method == "appium": + return {"error": "mock: appium boundary failure"} + + if method == "appium": + params = req.get("params") or {} + script = params.get("script", "") + if script == "mobile: activeAppInfo": + return {"result": {"bundleId": "com.apple.springboard", "name": "SpringBoard"}} + return {"result": {"script": script}} + + if method == "screenshot": + return {"result": {"path": "/tmp/iph-mock-shot.png", "bytes": 0}} + if method == "window_size": + return {"result": {"width": 390, "height": 844}} + if method == "page_source": + return {"result": ""} + if method == "click_element": + return {"result": {"matched": 1}} + if method == "send_keys": + return {"result": {"sent": req.get("params", {}).get("keys", ""), "matched": 1}} + if method == "set_value": + return {"result": {"set": req.get("params", {}).get("value", ""), "matched": 1}} + if method == "pick_wheel": + return {"result": {"value": "mock", "attempts": 1, "matched": True}} + if method == "raise": + raise RuntimeError("mock: intentional crash for tests") + if method == "garbage_response": + return "not-a-dict" + + # Screen stream — synthetic JPEG stub so tests don't need PIL. + if method == "screen_stream_start": + params = req.get("params") or {} + self._stream_fps = float(params.get("fps", 6)) + self._stream_quality = int(params.get("quality", 60)) + already = self._stream_running + self._stream_running = True + return {"result": { + "running": True, + "started": not already, + "updated": already, + "fps": self._stream_fps, + "quality": self._stream_quality, + "max_dim": int(params.get("max_dim", 800)), + }} + if method == "screen_stream_frame": + if not self._stream_running: + return {"result": {"ready": False, "frame_no": 0}} + self._stream_frame_no += 1 + import base64 + jpeg_stub = bytes.fromhex( + "ffd8ffe000104a46494600010100000100010000ffdb004300080606070605" + "08070707090908" + ) + return {"result": { + "ready": True, + "frame_no": self._stream_frame_no, + "jpeg_b64": base64.b64encode(jpeg_stub).decode("ascii"), + "fps": self._stream_fps, + "quality": self._stream_quality, + }} + if method == "screen_stream_stop": + was = self._stream_running + self._stream_running = False + self._stream_frame_no = 0 + return {"result": {"running": False, "stopped": was}} + + return {"error": f"mock: unknown method {method!r}"} + + +async def serve(d): + async def handler(reader, writer): + try: + line = await reader.readline() + if not line: + return + try: + req = json.loads(line) + except json.JSONDecodeError as e: + writer.write((json.dumps({"error": f"bad json: {e}"}) + "\n").encode()) + await writer.drain() + return + resp = await d.handle(req) + writer.write((json.dumps(resp, default=str) + "\n").encode()) + await writer.drain() + except Exception as e: + try: + writer.write((json.dumps({"error": str(e)}) + "\n").encode()) + await writer.drain() + except Exception: + pass + finally: + writer.close() + + serve_task = asyncio.create_task(ipc.serve(NAME, handler)) + stop_task = asyncio.create_task(d.stop.wait()) + await asyncio.sleep(0.05) + try: + await asyncio.wait({serve_task, stop_task}, return_when=asyncio.FIRST_COMPLETED) + finally: + for t in (serve_task, stop_task): + t.cancel() + try: + await t + except (asyncio.CancelledError, Exception): + pass + ipc.cleanup_endpoint(NAME) + + +async def _main(): + d = MockDaemon() + d.stop = asyncio.Event() + await serve(d) + + +def already_running(): + return ipc.ping(NAME, timeout=0.5) + + +if __name__ == "__main__": + if already_running(): + print(f"mock daemon already running for {NAME}", file=sys.stderr) + sys.exit(0) + open(LOG, "w").close() + open(PID, "w").write(str(os.getpid())) + try: + asyncio.run(_main()) + except KeyboardInterrupt: + pass + finally: + try: + os.unlink(PID) + except FileNotFoundError: + pass diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py new file mode 100644 index 0000000..1db9fa7 --- /dev/null +++ b/tests/test_agent_loop.py @@ -0,0 +1,270 @@ +"""Smoke tests for mobile_use.agent_loop.AgentLoop. + +Mocks iphone_harness/android_harness helpers + admin so AgentLoop can be +exercised without a real device. Closes the coverage gap identified in +the goal-007 audit. +""" +import sys +import types +from unittest import mock + +import pytest + + +def _make_fake_helpers(name="ios"): + """Return a fake helpers module that records every call.""" + calls = [] + m = types.ModuleType(f"fake_{name}_helpers") + + def screenshot(path=None): + calls.append(("screenshot", path)) + return "/tmp/fake.png" + + def ui_tree(visible_only=False, compact=False): + calls.append(("ui_tree", visible_only, compact)) + return [{"type": "Button", "label": "Send", "visible": True}] + + def active_app(): + calls.append(("active_app",)) + return {"bundleId": "com.example.app", "name": "Example"} + + def window_size(): + calls.append(("window_size",)) + return {"width": 390, "height": 844} + + def alert(): + calls.append(("alert",)) + return None + + def auto_dismiss_dialog(): + calls.append(("auto_dismiss_dialog",)) + return False + + def tap(el): + calls.append(("tap", el)) + return True + + def press_back(): + calls.append(("press_back",)) + return True + + def appium(script, **kw): + calls.append(("appium", script, kw)) + return True + + def find(**kw): + calls.append(("find", kw)) + return {"x": 100, "y": 200} + + for fn in (screenshot, ui_tree, active_app, window_size, alert, + auto_dismiss_dialog, tap, press_back, appium, find): + setattr(m, fn.__name__, fn) + + m._calls = calls + return m + + +def _make_fake_admin(): + m = types.ModuleType("fake_admin") + m._daemon_calls = 0 + + def ensure_daemon(*a, **kw): + m._daemon_calls += 1 + return True + + m.ensure_daemon = ensure_daemon + return m + + +def _install_fakes(monkeypatch, platform="ios"): + fake_h = _make_fake_helpers(platform) + fake_a = _make_fake_admin() + if platform == "ios": + # Pre-import so the parent package exists, then patch both sys.modules + # AND the parent-package attribute (which is what `import pkg.sub as x` reads). + import iphone_harness + monkeypatch.setitem(sys.modules, "iphone_harness.helpers", fake_h) + monkeypatch.setitem(sys.modules, "iphone_harness.admin", fake_a) + monkeypatch.setattr(iphone_harness, "helpers", fake_h, raising=False) + monkeypatch.setattr(iphone_harness, "admin", fake_a, raising=False) + else: + import android_harness + monkeypatch.setitem(sys.modules, "android_harness.helpers", fake_h) + monkeypatch.setitem(sys.modules, "android_harness.admin", fake_a) + monkeypatch.setattr(android_harness, "helpers", fake_h, raising=False) + monkeypatch.setattr(android_harness, "admin", fake_a, raising=False) + return fake_h, fake_a + + +# ---- instantiation -------------------------------------------------------- + +def test_agent_loop_imports(): + from mobile_use.agent_loop import AgentLoop, run_agent + assert AgentLoop is not None + assert callable(run_agent) + + +def test_agent_loop_instantiates_without_device(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + from mobile_use.agent_loop import AgentLoop + loop = AgentLoop(platform="ios", session_name="test-session", collect=False) + assert loop.platform == "ios" + assert loop._helpers is None + assert loop._admin is None + + +def test_agent_loop_unknown_platform_raises(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + from mobile_use.agent_loop import AgentLoop + loop = AgentLoop(platform="windows", session_name="x", collect=False) + with pytest.raises(RuntimeError, match="Unknown platform"): + loop._load_platform() + + +# ---- start / daemon ------------------------------------------------------- + +def test_agent_loop_start_calls_ensure_daemon(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + _h, a = _install_fakes(monkeypatch, "ios") + from mobile_use.agent_loop import AgentLoop + loop = AgentLoop(platform="ios", session_name="test", collect=False) + loop.start() + assert a._daemon_calls == 1 + + +# ---- perceive ------------------------------------------------------------- + +def test_perceive_captures_full_state(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + h, _a = _install_fakes(monkeypatch, "ios") + from mobile_use.agent_loop import AgentLoop + loop = AgentLoop(platform="ios", session_name="perceive-test", collect=False) + loop.start() + state = loop.perceive() + assert state["screenshot_path"] == "/tmp/fake.png" + assert state["ui_tree"] == [{"type": "Button", "label": "Send", "visible": True}] + assert state["active_app"]["bundleId"] == "com.example.app" + assert state["window_size"] == {"width": 390, "height": 844} + assert state["alert"] is None + + +def test_perceive_handles_helper_failures(tmp_path, monkeypatch): + """If a helper raises, perceive captures the error and continues.""" + monkeypatch.setenv("HOME", str(tmp_path)) + h, _a = _install_fakes(monkeypatch, "ios") + def boom(*a, **kw): + raise RuntimeError("device asleep") + h.screenshot = boom + h.ui_tree = boom + + from mobile_use.agent_loop import AgentLoop + loop = AgentLoop(platform="ios", session_name="fail-test", collect=False) + loop.start() + state = loop.perceive() + assert "screenshot_error" in state + assert "ui_tree_error" in state + assert state["ui_tree"] == [] + assert state["active_app"]["bundleId"] == "com.example.app" + + +# ---- act ------------------------------------------------------------------ + +def test_act_dispatches_to_helper(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + h, _a = _install_fakes(monkeypatch, "ios") + from mobile_use.agent_loop import AgentLoop + loop = AgentLoop(platform="ios", session_name="act-test", collect=False) + loop.start() + result = loop.act("tap", el={"x": 50, "y": 100}) + assert result == {"result": True} + assert ("tap", {"x": 50, "y": 100}) in h._calls + + +def test_act_unknown_action_returns_error(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + _install_fakes(monkeypatch, "ios") + from mobile_use.agent_loop import AgentLoop + loop = AgentLoop(platform="ios", session_name="act-bad", collect=False) + loop.start() + result = loop.act("nonsense_action") + assert "error" in result + assert "Unknown action" in result["error"] + + +def test_act_captures_helper_exception(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + h, _a = _install_fakes(monkeypatch, "ios") + def fail_tap(el): + raise ValueError("element not visible") + h.tap = fail_tap + + from mobile_use.agent_loop import AgentLoop + loop = AgentLoop(platform="ios", session_name="act-fail", collect=False) + loop.start() + result = loop.act("tap", el={}) + assert "error" in result + assert "element not visible" in result["error"] + + +# ---- undo ----------------------------------------------------------------- + +def test_undo_last_when_empty_returns_error(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + _install_fakes(monkeypatch, "ios") + from mobile_use.agent_loop import AgentLoop + loop = AgentLoop(platform="ios", session_name="undo-empty", collect=False) + loop.start() + result = loop.undo_last() + assert result == {"error": "No actions to undo"} + + +def test_undo_last_pops_action_stack(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + h, _a = _install_fakes(monkeypatch, "android") + from mobile_use.agent_loop import AgentLoop + loop = AgentLoop(platform="android", session_name="undo", collect=False) + loop.start() + loop.act("tap", el={"x": 1, "y": 2}) + assert len(loop._action_stack) == 1 + result = loop.undo_last() + assert "undone" in result + assert ("press_back",) in h._calls + assert len(loop._action_stack) == 0 + + +# ---- context + discovery -------------------------------------------------- + +def test_get_available_actions_lists_helpers(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + _install_fakes(monkeypatch, "ios") + from mobile_use.agent_loop import AgentLoop + loop = AgentLoop(platform="ios", session_name="actions", collect=False) + actions = loop.get_available_actions() + assert "tap" in actions + assert "screenshot" in actions + assert "ui_tree" in actions + # private helpers excluded + assert not any(a.startswith("_") for a in actions) + + +def test_get_context_returns_summary(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + _install_fakes(monkeypatch, "ios") + from mobile_use.agent_loop import AgentLoop + loop = AgentLoop(platform="ios", session_name="ctx", collect=False) + loop.start() + loop.perceive() + ctx = loop.get_context() + assert ctx["platform"] == "ios" + assert "available_actions" in ctx + assert "session_summary" in ctx + + +def test_find_element_delegates_to_helper(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + h, _a = _install_fakes(monkeypatch, "ios") + from mobile_use.agent_loop import AgentLoop + loop = AgentLoop(platform="ios", session_name="find", collect=False) + el = loop.find_element(label="Send") + assert el == {"x": 100, "y": 200} + assert ("find", {"label": "Send"}) in h._calls diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index ce56098..cbe4877 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -65,7 +65,8 @@ def fake_check_call(*a, **kw): def test_run_returns_0_when_everything_installed(monkeypatch, capsys): from mobile_use import bootstrap - monkeypatch.setattr(bootstrap, "_have", _stub_have({"brew", "node", "npm", "appium"})) + monkeypatch.setattr(bootstrap, "_have", _stub_have({"brew", "node", "npm", "appium", "xcodebuild"})) + monkeypatch.setattr(bootstrap, "_have_xcode", lambda: True) monkeypatch.setattr(bootstrap, "_brew_has", lambda pkg: True) monkeypatch.setattr(bootstrap, "_appium_driver_installed", lambda name: True) monkeypatch.setattr(bootstrap, "_python_pkg_importable", lambda: True) @@ -87,3 +88,183 @@ def test_main_dry_run_smoke(): # Should never raise even on a partially-set-up dev box. rc = bootstrap.main(["--dry-run"]) assert rc in (0, 1) # 0 if everything installed; 1 if brew missing + + +# ---- linux package manager detection ------------------------------------- + +def test_linux_pkg_manager_returns_none_on_macos(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "darwin") + assert bootstrap._linux_pkg_manager() is None + + +def test_linux_pkg_manager_detects_ubuntu(monkeypatch, tmp_path): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "linux") + fake = tmp_path / "os-release" + fake.write_text('ID=ubuntu\nID_LIKE=debian\n') + # bootstrap reads /etc/os-release directly, so we need to patch Path or read. + # Simpler: stub _linux_pkg_manager helpers via monkeypatch of the function inputs. + # Use unittest.mock on Path.read_text via a wrapper. + real_read = bootstrap.Path.read_text + def fake_read(self, *a, **kw): + if str(self) == "/etc/os-release": + return 'ID=ubuntu\nID_LIKE=debian\n' + return real_read(self, *a, **kw) + monkeypatch.setattr(bootstrap.Path, "read_text", fake_read) + assert bootstrap._linux_pkg_manager() == "apt" + + +def test_linux_pkg_manager_detects_fedora(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "linux") + real_read = bootstrap.Path.read_text + def fake_read(self, *a, **kw): + if str(self) == "/etc/os-release": + return 'ID=fedora\n' + return real_read(self, *a, **kw) + monkeypatch.setattr(bootstrap.Path, "read_text", fake_read) + assert bootstrap._linux_pkg_manager() == "dnf" + + +def test_linux_pkg_manager_detects_arch(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "linux") + real_read = bootstrap.Path.read_text + def fake_read(self, *a, **kw): + if str(self) == "/etc/os-release": + return 'ID=arch\n' + return real_read(self, *a, **kw) + monkeypatch.setattr(bootstrap.Path, "read_text", fake_read) + assert bootstrap._linux_pkg_manager() == "pacman" + + +def test_linux_adb_install_cmd_apt(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap, "_linux_pkg_manager", lambda: "apt") + cmd = bootstrap._linux_adb_install_cmd() + assert "apt" in " ".join(cmd) + assert "android-tools-adb" in " ".join(cmd) + + +def test_linux_adb_install_cmd_dnf(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap, "_linux_pkg_manager", lambda: "dnf") + cmd = bootstrap._linux_adb_install_cmd() + assert "dnf" in " ".join(cmd) + + +def test_linux_adb_install_cmd_pacman(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap, "_linux_pkg_manager", lambda: "pacman") + cmd = bootstrap._linux_adb_install_cmd() + assert "pacman" in " ".join(cmd) + + +def test_linux_adb_install_returns_none_when_unknown(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap, "_linux_pkg_manager", lambda: None) + assert bootstrap._linux_adb_install_cmd() is None + + +def test_linux_node_install_apt(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap, "_linux_pkg_manager", lambda: "apt") + cmd = bootstrap._linux_node_install_cmd() + assert "nodejs" in " ".join(cmd) + assert "npm" in " ".join(cmd) + + +def test_linux_plan_uses_apt_for_adb(monkeypatch): + """On Linux+apt, plan should include an apt install command for adb.""" + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "linux") + monkeypatch.setattr(bootstrap, "_linux_pkg_manager", lambda: "apt") + steps = bootstrap.plan(ios=False, android=True) + # Find the adb step + for label, check, cmd, mac_only in steps: + if "adb" in label.lower(): + assert cmd is not None + assert "apt" in " ".join(cmd) + assert mac_only is False + break + else: + pytest.fail("no adb step found in Linux plan") + + +def test_linux_plan_skips_xcuitest_in_android_only(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "linux") + monkeypatch.setattr(bootstrap, "_linux_pkg_manager", lambda: "apt") + steps = bootstrap.plan(ios=False, android=True) + labels = " ".join(label for label, *_ in steps) + assert "xcuitest" not in labels + assert "libimobiledevice" not in labels + + +# ---- xcode preflight ----------------------------------------------------- + +def test_have_xcode_returns_true_off_macos(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "linux") + assert bootstrap._have_xcode() is True + + +def test_have_xcode_returns_false_when_xcodebuild_missing(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "darwin") + monkeypatch.setattr(bootstrap, "_have", lambda c: False) + assert bootstrap._have_xcode() is False + + +def test_have_xcode_returns_true_when_xcodebuild_works(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "darwin") + monkeypatch.setattr(bootstrap, "_have", lambda c: True) + monkeypatch.setattr(bootstrap.subprocess, "check_output", lambda *a, **kw: b"Xcode 16.0\n") + assert bootstrap._have_xcode() is True + + +def test_have_xcode_returns_false_when_xcodebuild_errors(monkeypatch): + """xcode-select error on CLT-only setup must return False, not raise.""" + import subprocess as sp + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "darwin") + monkeypatch.setattr(bootstrap, "_have", lambda c: True) + def boom(*a, **kw): + raise sp.CalledProcessError(1, ["xcodebuild"], output=b"xcode-select: error: tool xcodebuild requires Xcode") + monkeypatch.setattr(bootstrap.subprocess, "check_output", boom) + assert bootstrap._have_xcode() is False + + +def test_plan_includes_xcode_step_for_ios(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "darwin") + steps = bootstrap.plan(ios=True, android=False) + labels = [s[0] for s in steps] + assert any("Xcode" in lbl for lbl in labels), f"Xcode step missing from iOS plan: {labels}" + + +def test_plan_omits_xcode_when_android_only(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "darwin") + steps = bootstrap.plan(ios=False, android=True) + labels = [s[0] for s in steps] + assert not any("Xcode" in lbl for lbl in labels), f"Xcode in android-only plan: {labels}" + + +def test_run_halts_with_xcode_remediation_when_missing(monkeypatch, capsys): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "darwin") + monkeypatch.setattr(bootstrap, "_have_xcode", lambda: False) + monkeypatch.setattr(bootstrap, "_have", lambda c: True) + monkeypatch.setattr(bootstrap, "_brew_has", lambda pkg: True) + monkeypatch.setattr(bootstrap, "_appium_driver_installed", lambda name: True) + monkeypatch.setattr(bootstrap, "_python_pkg_importable", lambda: True) + + rc = bootstrap.run(ios=True, android=False, dry_run=True) + out = capsys.readouterr().out + assert "MISSING" in out + assert "App Store" in out + assert "xcode-select" in out + assert rc == 1 diff --git a/tests/test_bootstrap_linux.py b/tests/test_bootstrap_linux.py new file mode 100644 index 0000000..5181ddb --- /dev/null +++ b/tests/test_bootstrap_linux.py @@ -0,0 +1,156 @@ +"""Bootstrap plan + run behavior on Linux hosts. + +Complements test_bootstrap.py (which is platform-agnostic and parametric) +by exercising the Linux-specific code paths end to end: + + - `mobile-use bootstrap` on Linux skips iOS steps with a clear message + - `--android-only` on Linux produces a runnable plan + - libimobiledevice/Xcode/brew steps are gated by mac_only + - the dry-run output for Linux is free of `brew install` directives + - the final summary references SETUP.md "Linux setup" anchor on failure +""" +import sys + +import pytest + + +def _setup_linux(monkeypatch, pkg_manager="apt"): + """Common: pretend we're on Linux with a known pkg manager + sudo available.""" + monkeypatch.setattr(sys, "platform", "linux") + from mobile_use import _platform, bootstrap + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: pkg_manager) + monkeypatch.setattr(_platform, "sudo_prefix", lambda: ["sudo"]) + # Local shims read from bootstrap module — patch them too + monkeypatch.setattr(bootstrap, "_linux_pkg_manager", lambda: pkg_manager) + monkeypatch.setattr(bootstrap, "_sudo_prefix", lambda: ["sudo"]) + + +def test_plan_on_linux_skips_xcode_step_label(monkeypatch): + _setup_linux(monkeypatch) + from mobile_use import bootstrap + steps = bootstrap.plan(ios=True, android=True) + # Xcode still in plan (label-only); run() will SKIP it on non-darwin + labels = [s[0] for s in steps] + xcode = [l for l in labels if "Xcode" in l] + assert xcode, "Xcode label should still appear in plan (run-time SKIP)" + + +def test_plan_on_linux_android_only_uses_apt(monkeypatch): + _setup_linux(monkeypatch, pkg_manager="apt") + from mobile_use import bootstrap + steps = bootstrap.plan(ios=False, android=True) + # find adb step + adb_step = next((s for s in steps if "adb" in s[0].lower()), None) + assert adb_step is not None + label, _check, cmd, mac_only = adb_step + assert mac_only is False + assert cmd is not None + assert "apt" in " ".join(cmd) + assert "android-tools-adb" in " ".join(cmd) + + +def test_plan_on_linux_android_only_uses_dnf(monkeypatch): + _setup_linux(monkeypatch, pkg_manager="dnf") + from mobile_use import bootstrap + steps = bootstrap.plan(ios=False, android=True) + adb_step = next((s for s in steps if "adb" in s[0].lower()), None) + assert adb_step is not None + assert "dnf" in " ".join(adb_step[2]) + + +def test_plan_on_linux_skips_libimobiledevice_brew(monkeypatch): + _setup_linux(monkeypatch) + from mobile_use import bootstrap + steps = bootstrap.plan(ios=True, android=False) + # libimobiledevice step is mac_only — keep the label, run-time SKIP + libi = [s for s in steps if "libimobiledevice" in s[0]] + assert libi, "libimobiledevice step should still appear in plan" + assert all(s[3] is True for s in libi), "libimobiledevice should be mac_only=True" + + +def test_run_on_linux_does_not_print_brew_install_for_skipped_ios(monkeypatch, capsys): + """Linux dry-run output must not direct the user to `brew install …` + for iOS-side steps. iOS steps print SKIP with the remote-Mac hint.""" + _setup_linux(monkeypatch) + from mobile_use import bootstrap + # Force every cross-platform check to pretend "missing" so we see the + # remediation messages, but keep dry-run True so nothing executes. + monkeypatch.setattr(bootstrap, "_have", lambda c: False) + monkeypatch.setattr(bootstrap, "_brew_has", lambda p: False) + monkeypatch.setattr(bootstrap, "_appium_driver_installed", lambda n: False) + monkeypatch.setattr(bootstrap, "_python_pkg_importable", lambda: False) + + bootstrap.run(ios=True, android=True, dry_run=True) + out = capsys.readouterr().out + # No `brew install` directive should target Linux users + skipped_lines = [l for l in out.splitlines() if "SKIP" in l] + assert skipped_lines, "iOS steps should be SKIP-marked on Linux" + for line in skipped_lines: + assert "brew install" not in line, f"brew install leaked into SKIP line: {line}" + # The mac-only iOS skip should mention remote IPH_APPIUM_URL or Xcode/Mac + assert any("IPH_APPIUM_URL" in l or "Mac" in l or "Xcode" in l for l in skipped_lines) + + +def test_run_on_linux_android_only_full_dry_run(monkeypatch, capsys): + """End-to-end: bootstrap --android-only --dry-run on Linux exits 0.""" + _setup_linux(monkeypatch, pkg_manager="apt") + from mobile_use import bootstrap + # Pretend everything is missing — exercise every install command path + monkeypatch.setattr(bootstrap, "_have", lambda c: False) + monkeypatch.setattr(bootstrap, "_appium_driver_installed", lambda n: False) + monkeypatch.setattr(bootstrap, "_python_pkg_importable", lambda: False) + # Critical: subprocess.check_call should never be invoked on dry-run + monkeypatch.setattr(bootstrap.subprocess, "check_call", + lambda *a, **kw: (_ for _ in ()).throw(AssertionError("dry-run should not call subprocess"))) + + rc = bootstrap.run(ios=False, android=True, dry_run=True) + out = capsys.readouterr().out + # The plan steps should print would-run lines for adb/node/appium/uia2/pkg + assert "android-tools-adb" in out + assert "nodejs" in out + assert "appium" in out + # Linux: no `brew install` in any remediation + assert "brew install" not in out + # rc=0 means no MISSING-with-no-cmd terminated bootstrap. (Some steps + # may still print MISSING/would-run; what matters is no crash.) + assert rc in (0, 1) + + +def test_run_on_linux_unknown_distro_shows_all_manager_hints(monkeypatch, capsys): + """When pkg manager can't be detected, MISSING line lists apt/dnf/pacman/zypper/apk.""" + monkeypatch.setattr(sys, "platform", "linux") + from mobile_use import _platform, bootstrap + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: None) + monkeypatch.setattr(_platform, "sudo_prefix", lambda: ["sudo"]) + monkeypatch.setattr(bootstrap, "_linux_pkg_manager", lambda: None) + monkeypatch.setattr(bootstrap, "_sudo_prefix", lambda: ["sudo"]) + monkeypatch.setattr(bootstrap, "_have", lambda c: False) + monkeypatch.setattr(bootstrap, "_appium_driver_installed", lambda n: False) + monkeypatch.setattr(bootstrap, "_python_pkg_importable", lambda: False) + + bootstrap.run(ios=False, android=True, dry_run=False) + out = capsys.readouterr().out + # adb MISSING line should list all known package managers + assert "android-tools-adb" in out or "android-tools" in out + assert "apt install" in out + assert "dnf install" in out + assert "pacman -S" in out + # zypper + apk added in D1 — verify they're in the doctor hint too + assert "zypper" in out + assert "apk" in out + + +def test_run_summary_references_linux_setup_section_on_failure(monkeypatch, capsys): + """Failing bootstrap on Linux ends with SETUP.md '## Linux setup' pointer.""" + _setup_linux(monkeypatch, pkg_manager=None) # no pkg manager → MISSING → rc=1 + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap, "_have", lambda c: False) + monkeypatch.setattr(bootstrap, "_appium_driver_installed", lambda n: False) + monkeypatch.setattr(bootstrap, "_python_pkg_importable", lambda: False) + monkeypatch.setattr(bootstrap, "_linux_pkg_manager", lambda: None) + + rc = bootstrap.run(ios=False, android=True, dry_run=False) + out = capsys.readouterr().out + assert rc != 0 + assert "SETUP.md" in out + assert "Linux setup" in out diff --git a/tests/test_cli_dispatch.py b/tests/test_cli_dispatch.py new file mode 100644 index 0000000..d16c9c7 --- /dev/null +++ b/tests/test_cli_dispatch.py @@ -0,0 +1,219 @@ +"""CLI dispatch tests — every subcommand routes correctly + clean errors. + +Covers the gaps identified by the coverage audit: +- agent subcommand wiring +- ios sign-wda CLI dispatch (not just the underlying module) +- -c bad code / syntax error / runtime error +- ios subcommand argument validation +- doctor exit codes +- bootstrap idempotency +- mock-vs-real RPC parity (contract test) +""" +import os +import subprocess +import sys +from pathlib import Path +from unittest import mock + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _run_cli(*args, env=None, timeout=20): + """Run mobile_use.cli as a subprocess; return (rc, stdout, stderr).""" + e = {**os.environ, "PYTHONPATH": str(REPO_ROOT)} + if env: + e.update(env) + p = subprocess.run( + [sys.executable, "-m", "mobile_use.cli", *args], + cwd=str(REPO_ROOT), capture_output=True, timeout=timeout, env=e, + ) + return p.returncode, p.stdout.decode(errors="replace"), p.stderr.decode(errors="replace") + + +# ---- ios subcommand argument validation ----------------------------------- + +def test_ios_no_action_prints_help(): + rc, out, _ = _run_cli("ios") + # No action → help with non-zero rc (usage error) + assert "sign-wda" in out + + +def test_ios_help_lists_actions(): + rc, out, _ = _run_cli("ios", "--help") + assert rc == 0 + assert "sign-wda" in out + + +def test_ios_unknown_action_errors_clearly(): + rc, _, err = _run_cli("ios", "made-up-action") + assert rc != 0 + assert "Unknown" in err or "Unknown" in _run_cli("ios", "made-up-action")[1] + + +def test_ios_sign_wda_check_runs(): + rc, out, _ = _run_cli("ios", "sign-wda", "--check") + # 0 if signed, 1 if not — both valid here. Just confirm it routed and printed. + assert rc in (0, 1) + assert "WDA signing" in out + + +def test_ios_sign_wda_help_returns_zero(): + rc, out, _ = _run_cli("ios", "sign-wda", "--help") + assert rc == 0 + assert "--check" in out + + +# ---- -c (exec) error handling --------------------------------------------- + +def test_exec_no_traceback_noise_from_cli_internals(): + """User script error should not surface cli.py traceback frames.""" + rc, out, err = _run_cli("--ios", "-c", "raise ValueError('user bug')") + # rc may be 1 (script error) OR show daemon-unreachable depending on env. + # In either case, "cli.py" should NOT appear in the user-visible output. + if "ValueError" in err: + # If the script error reached us, it shouldn't include cli.py frames + assert "cli.py" not in err or "/cli.py" not in err.split("ValueError")[0] + + +def test_exec_syntax_error_clean_message(): + """SyntaxError in -c snippet → friendly one-line error (when env is ready).""" + rc, _, err = _run_cli("--ios", "-c", "def (", env={"IPH_UDID": "stub-udid-for-test"}) + if rc != 0 and "daemon didn't come up" not in err and "daemon did not come up" not in err: + assert "Syntax error" in err or "SyntaxError" in err or "daemon" in err.lower() + + +# ---- bootstrap idempotency ------------------------------------------------ + +def test_bootstrap_dry_run_twice_produces_same_plan(): + rc1, out1, _ = _run_cli("bootstrap", "--dry-run") + rc2, out2, _ = _run_cli("bootstrap", "--dry-run") + assert rc1 == rc2 + # The numbered steps + their statuses should be identical run-to-run. + norm = lambda s: "\n".join(l for l in s.splitlines() if l.startswith("[")) + assert norm(out1) == norm(out2) + + +def test_bootstrap_help_works(): + rc, out, _ = _run_cli("bootstrap", "--help") + assert rc == 0 + assert "--dry-run" in out + assert "--ios-only" in out + + +def test_bootstrap_ios_android_mutex(): + """--ios-only --android-only must reject (rc=2).""" + rc, _, _ = _run_cli("bootstrap", "--ios-only", "--android-only") + assert rc == 2 + + +# ---- doctor exit codes ---------------------------------------------------- + +def test_doctor_runs_to_completion(): + rc, out, _ = _run_cli("--doctor") + # Without a device, doctor returns 1; with one, 0. Both must run to end. + assert rc in (0, 1) + assert "iphone-harness" in out or "iOS" in out + assert "android-harness" in out or "Android" in out + + +# ---- agent subcommand ----------------------------------------------------- + +def test_agent_subcommand_dispatches(): + """agent subcommand should at minimum reach run_agent (may fail later — ok).""" + rc, out, err = _run_cli("agent", "--help", timeout=10) + # Without explicit help support in agent_loop, this may fall through. + # Just confirm the CLI doesn't crash with unhandled exception. + # Allowed: rc 0 (help printed) or rc 1 (no device or no help) — not unhandled. + assert rc in (0, 1) + + +# ---- mock-vs-real RPC parity (contract test) ------------------------------ + +def _real_dispatch_keys(daemon_module_name): + """Import the real daemon module and return its _DISPATCH keys.""" + import importlib + mod = importlib.import_module(daemon_module_name) + return set(mod._DISPATCH.keys()) + + +def _mock_handles_methods(mock_module_name): + """Read mock module source and find every `method ==` literal it dispatches.""" + import importlib.util + spec = importlib.util.find_spec(mock_module_name) + src = Path(spec.origin).read_text() + import re + return set(re.findall(r'method\s*==\s*"([a-z_]+)"', src)) + + +def test_iphone_mock_covers_real_daemon_methods(): + real = _real_dispatch_keys("iphone_harness.daemon") + mock = _mock_handles_methods("tests._mock_iphone_daemon") + missing = real - mock + assert not missing, ( + f"Mock iphone daemon doesn't handle real RPC methods: {missing}. " + f"Tests passing against the mock could give false confidence." + ) + + +def test_android_mock_covers_real_daemon_methods(): + real = _real_dispatch_keys("android_harness.daemon") + mock = _mock_handles_methods("tests._mock_android_daemon") + missing = real - mock + assert not missing, ( + f"Mock android daemon doesn't handle real RPC methods: {missing}." + ) + + +# ---- CLI imports cleanly -------------------------------------------------- + +def test_cli_module_imports_with_no_args(): + """Just `python -m mobile_use.cli` (no args) prints help; rc 0.""" + rc, out, _ = _run_cli() + assert rc == 0 + assert "mobile-use" in out + + +def test_cli_version(): + rc, out, _ = _run_cli("--version") + assert rc == 0 + assert "mobile-use" in out.lower() + + +# ---- .env validation ------------------------------------------------------ + +def test_check_env_passes_when_udid_in_env(monkeypatch): + from mobile_use import cli + monkeypatch.setenv("IPH_UDID", "abc123") + assert cli._check_env_for_platform("ios") is None + + +def test_check_env_returns_message_when_no_env_no_var(monkeypatch, tmp_path): + from mobile_use import cli + monkeypatch.delenv("IPH_UDID", raising=False) + monkeypatch.delenv("ANH_UDID", raising=False) + # Point os.path to a tmp dir that has no .env + monkeypatch.setattr(cli.os.path, "exists", lambda p: False) + msg = cli._check_env_for_platform("ios") + assert msg is not None + assert "mobile-use init" in msg + assert "IPH_UDID" in msg + + +def test_check_env_android_variant(monkeypatch): + from mobile_use import cli + monkeypatch.delenv("ANH_UDID", raising=False) + monkeypatch.setattr(cli.os.path, "exists", lambda p: False) + msg = cli._check_env_for_platform("android") + assert msg is not None + assert "ANH_UDID" in msg + + +def test_check_env_skips_when_env_file_exists(monkeypatch, tmp_path): + from mobile_use import cli + monkeypatch.delenv("IPH_UDID", raising=False) + # Pretend .env exists (so user has run init) + monkeypatch.setattr(cli.os.path, "exists", lambda p: True) + assert cli._check_env_for_platform("ios") is None diff --git a/tests/test_cli_headed.py b/tests/test_cli_headed.py new file mode 100644 index 0000000..d1a6111 --- /dev/null +++ b/tests/test_cli_headed.py @@ -0,0 +1,184 @@ +"""CLI --headed / --headless flag wiring tests. + +Verifies the CLI: + - documents --headed and --headless in --help + - sets MOBILE_USE_HEADED=1 when --headed is passed + - sets MOBILE_USE_HEADED=0 when --headless is passed (explicit opt-out) + - leaves MOBILE_USE_HEADED unset when neither is passed (default = off) + - actually spawns a ViewerServer when --headed is passed (covered via a + monkeypatch + in-process call to the platform _run_* function) + +The actual ViewerServer + MJPEG plumbing is exercised by test_viewer_mjpeg.py; +this file only checks the CLI hook fires. +""" +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _run_cli(args, env_extra=None, timeout=10.0): + env = {**os.environ} + if env_extra: + env.update(env_extra) + p = subprocess.run( + [sys.executable, "-m", "mobile_use.cli", *args], + env=env, capture_output=True, text=True, timeout=timeout, + cwd=str(REPO_ROOT), + ) + return p.returncode, p.stdout, p.stderr + + +# ---- HELP docs ----------------------------------------------------------- + +def test_cli_headed_flag_advertised_in_help(): + rc, out, _ = _run_cli(["--help"]) + assert rc == 0 + assert "--headed" in out + assert "--headless" in out + # Implementation detail user shouldn't have to know — but the help should + # mention the viewer so users understand what --headed does. + assert "viewer" in out.lower() or "mirror" in out.lower() + + +# ---- env-var wiring (introspect via a script that prints the env var) --- + +def _print_headed_env_script(): + """Tiny script: print the value of MOBILE_USE_HEADED.""" + return "import os; print('HEADED=' + repr(os.environ.get('MOBILE_USE_HEADED')))" + + +def test_cli_headed_flag_sets_env(monkeypatch): + """--headed should set MOBILE_USE_HEADED=1 before _run_ios/_run_android runs. + Verified by calling main() in-process and observing os.environ.""" + monkeypatch.delenv("MOBILE_USE_HEADED", raising=False) + # Stub out _run_ios + _run_android so we don't actually invoke ensure_daemon. + from mobile_use import cli + seen = {} + monkeypatch.setattr(cli, "_run_ios", lambda args: seen.setdefault("ios", os.environ.get("MOBILE_USE_HEADED"))) + monkeypatch.setattr(cli, "_run_android", lambda args: None) + monkeypatch.setattr(cli, "_detect_platform", lambda: "ios") + monkeypatch.setattr(sys, "argv", ["mobile-use", "--ios", "--headed", "-c", "pass"]) + cli.main() + assert seen.get("ios") == "1" + + +def test_cli_headless_flag_sets_env_to_zero(monkeypatch): + monkeypatch.delenv("MOBILE_USE_HEADED", raising=False) + from mobile_use import cli + seen = {} + monkeypatch.setattr(cli, "_run_ios", lambda args: seen.setdefault("ios", os.environ.get("MOBILE_USE_HEADED"))) + monkeypatch.setattr(cli, "_run_android", lambda args: None) + monkeypatch.setattr(cli, "_detect_platform", lambda: "ios") + monkeypatch.setattr(sys, "argv", ["mobile-use", "--ios", "--headless", "-c", "pass"]) + cli.main() + assert seen.get("ios") == "0" + + +def test_cli_default_no_headed_env(monkeypatch): + """No --headed / --headless: env var NOT set by the CLI (preserves caller's value).""" + monkeypatch.delenv("MOBILE_USE_HEADED", raising=False) + from mobile_use import cli + seen = {} + monkeypatch.setattr(cli, "_run_ios", lambda args: seen.setdefault("ios", os.environ.get("MOBILE_USE_HEADED"))) + monkeypatch.setattr(cli, "_run_android", lambda args: None) + monkeypatch.setattr(cli, "_detect_platform", lambda: "ios") + monkeypatch.setattr(sys, "argv", ["mobile-use", "--ios", "-c", "pass"]) + cli.main() + assert seen.get("ios") is None # untouched + + +# ---- _maybe_start_viewer hook ------------------------------------------- + +def test_maybe_start_viewer_off_when_env_unset(monkeypatch): + monkeypatch.delenv("MOBILE_USE_HEADED", raising=False) + from mobile_use.cli import _maybe_start_viewer + assert _maybe_start_viewer("ios") is None + + +def test_maybe_start_viewer_logs_and_returns_none_on_failure(monkeypatch, capsys): + """If ViewerServer can't construct (no daemon, etc), the hook returns None + + logs a soft warning rather than crashing the user's -c script.""" + monkeypatch.setenv("MOBILE_USE_HEADED", "1") + # Force ViewerServer constructor to blow up. + import mobile_use.viewer.server as vs + real = vs.ViewerServer + + class _Boom(real): + def __init__(self, *a, **kw): + raise RuntimeError("viewer init blew up") + + monkeypatch.setattr(vs, "ViewerServer", _Boom) + from mobile_use.cli import _maybe_start_viewer + result = _maybe_start_viewer("ios") + assert result is None + err = capsys.readouterr().err + assert "viewer failed to start" in err + + +def test_maybe_start_viewer_real(monkeypatch): + """Smoke test: with a live mock daemon, _maybe_start_viewer spawns a real + ViewerServer + opens a URL. We assert the URL is reachable.""" + import subprocess as _sp + import sys as _sys + import time as _time + import uuid as _uuid + import urllib.request as _ur + + from iphone_harness import _ipc as ipc + import iphone_harness.helpers as ih + + name = f"tst{_uuid.uuid4().hex[:10]}" + monkeypatch.setenv("IPH_NAME", name) + monkeypatch.setattr(ih, "NAME", name) + monkeypatch.setenv("MOBILE_USE_HEADED", "1") + # Disable webbrowser.open so test doesn't try to actually open a tab. + import webbrowser + monkeypatch.setattr(webbrowser, "open", lambda *a, **kw: True) + + p = _sp.Popen( + [_sys.executable, "-m", "tests._mock_iphone_daemon"], + env={**os.environ, "IPH_NAME": name}, + stdout=_sp.DEVNULL, stderr=_sp.DEVNULL, + cwd=str(REPO_ROOT), start_new_session=True, + ) + try: + # Wait for daemon up. + deadline = _time.time() + 5.0 + while _time.time() < deadline: + if ipc.ping(name, timeout=0.3): + break + _time.sleep(0.05) + else: + pytest.fail("mock daemon never came up") + + from mobile_use.cli import _maybe_start_viewer + v = _maybe_start_viewer("ios") + assert v is not None + assert v.url.startswith("http://127.0.0.1:") + try: + with _ur.urlopen(v.url, timeout=2.0) as r: + assert r.status == 200 + finally: + v.stop() + finally: + try: + s, _ = ipc.connect(name, timeout=1.0) + ipc.request(s, None, {"meta": "shutdown"}) + s.close() + except Exception: + pass + try: + p.wait(timeout=3.0) + except _sp.TimeoutExpired: + p.kill(); p.wait(timeout=2.0) + for ext in ("sock", "pid", "log"): + try: + (Path("/tmp") / f"iph-{name}.{ext}").unlink() + except FileNotFoundError: + pass diff --git a/tests/test_daemon_lifecycle.py b/tests/test_daemon_lifecycle.py new file mode 100644 index 0000000..e76fc35 --- /dev/null +++ b/tests/test_daemon_lifecycle.py @@ -0,0 +1,341 @@ +"""Daemon lifecycle tests — ensure_daemon, restart_daemon, stale-file cleanup. + +Uses mock daemons (tests._mock_iphone_daemon, tests._mock_android_daemon) so no +device or Appium is required. The real admin.ensure_daemon is exercised via +IPH_DAEMON_MODULE / ANH_DAEMON_MODULE env override. +""" +import os +import socket +import subprocess +import sys +import time +import uuid +from pathlib import Path + +import pytest + +from iphone_harness import _ipc as iph_ipc +from iphone_harness import admin as iph_admin +from android_harness import _ipc as anh_ipc +from android_harness import admin as anh_admin + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _wait_alive(ipc_mod, name, timeout=5.0): + deadline = time.time() + timeout + while time.time() < deadline: + if ipc_mod.ping(name, timeout=0.3): + return True + time.sleep(0.05) + return False + + +def _wait_dead(ipc_mod, name, timeout=5.0): + deadline = time.time() + timeout + while time.time() < deadline: + if not ipc_mod.ping(name, timeout=0.3): + return True + time.sleep(0.05) + return False + + +def _cleanup_files(prefix, name): + for ext in ("sock", "pid", "log"): + try: + (Path("/tmp") / f"{prefix}-{name}.{ext}").unlink() + except FileNotFoundError: + pass + + +@pytest.fixture +def iph_name(monkeypatch): + n = f"tst{uuid.uuid4().hex[:10]}" + monkeypatch.setenv("IPH_NAME", n) + monkeypatch.setenv("IPH_DAEMON_MODULE", "tests._mock_iphone_daemon") + yield n + _cleanup_files("iph", n) + + +@pytest.fixture +def anh_name(monkeypatch): + n = f"tst{uuid.uuid4().hex[:10]}" + monkeypatch.setenv("ANH_NAME", n) + monkeypatch.setenv("ANH_DAEMON_MODULE", "tests._mock_android_daemon") + yield n + _cleanup_files("anh", n) + + +def _spawn_mock(platform, name, extra_env=None): + if platform == "iphone": + module = "tests._mock_iphone_daemon" + env_var = "IPH_NAME" + else: + module = "tests._mock_android_daemon" + env_var = "ANH_NAME" + env = {**os.environ, env_var: name, **(extra_env or {})} + return subprocess.Popen( + [sys.executable, "-m", module], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + cwd=str(REPO_ROOT), + start_new_session=True, + ) + + +# ---- ensure_daemon (end-to-end via mock module) --------------------------- + +def test_ensure_daemon_spawns_when_dead(iph_name): + """ensure_daemon spawns mock daemon and returns when alive.""" + assert iph_ipc.ping(iph_name, timeout=0.3) is False + try: + iph_admin.ensure_daemon(wait=10.0, name=iph_name) + assert iph_ipc.ping(iph_name, timeout=1.0) is True + finally: + iph_admin.restart_daemon(iph_name) + _wait_dead(iph_ipc, iph_name, timeout=5.0) + + +def test_ensure_daemon_noop_when_alive(iph_name): + """Second call to ensure_daemon shouldn't spawn a new process.""" + try: + iph_admin.ensure_daemon(wait=10.0, name=iph_name) + pid1 = iph_ipc.identify(iph_name, timeout=1.0) + iph_admin.ensure_daemon(wait=10.0, name=iph_name) + pid2 = iph_ipc.identify(iph_name, timeout=1.0) + assert pid1 == pid2 + finally: + iph_admin.restart_daemon(iph_name) + _wait_dead(iph_ipc, iph_name, timeout=5.0) + + +def test_ensure_daemon_restarts_when_appium_handshake_fails(iph_name, monkeypatch): + """If daemon's Appium-side handshake errors, ensure_daemon respawns it.""" + # First spawn with broken appium boundary + monkeypatch.setenv("MOCK_FAIL_APPIUM", "1") + try: + iph_admin.ensure_daemon(wait=10.0, name=iph_name) + pid1 = iph_ipc.identify(iph_name, timeout=1.0) + # Now make a second ensure_daemon — handshake will fail, daemon restarted. + monkeypatch.delenv("MOCK_FAIL_APPIUM", raising=False) + iph_admin.ensure_daemon(wait=10.0, name=iph_name) + pid2 = iph_ipc.identify(iph_name, timeout=1.0) + assert pid2 != pid1 # New process after restart + finally: + iph_admin.restart_daemon(iph_name) + _wait_dead(iph_ipc, iph_name, timeout=5.0) + + +# ---- restart_daemon ------------------------------------------------------- + +def test_restart_daemon_kills_alive_daemon(iph_name): + p = _spawn_mock("iphone", iph_name) + try: + assert _wait_alive(iph_ipc, iph_name) + iph_admin.restart_daemon(iph_name) + assert _wait_dead(iph_ipc, iph_name) + finally: + if p.poll() is None: + p.kill() + p.wait(timeout=2.0) + + +def test_restart_daemon_noop_when_no_daemon(iph_name): + """Should not raise even if nothing to restart.""" + iph_admin.restart_daemon(iph_name) # should just complete + assert iph_ipc.ping(iph_name, timeout=0.3) is False + + +def test_restart_daemon_cleans_pid_file(iph_name): + p = _spawn_mock("iphone", iph_name) + try: + assert _wait_alive(iph_ipc, iph_name) + pid_path = Path(iph_ipc.pid_path(iph_name)) + assert pid_path.exists() + iph_admin.restart_daemon(iph_name) + _wait_dead(iph_ipc, iph_name) + assert not pid_path.exists() + finally: + if p.poll() is None: + p.kill() + p.wait(timeout=2.0) + + +# ---- stale file handling -------------------------------------------------- + +def test_stale_socket_file_does_not_block_new_daemon(iph_name): + """Leftover .sock from a hard-killed daemon should be cleaned on new spawn.""" + sock_path = Path(iph_ipc.sock_addr(iph_name)) + # Pre-create stale socket file (mimic kill -9 aftermath) + sock_path.write_text("") + assert sock_path.exists() + try: + iph_admin.ensure_daemon(wait=10.0, name=iph_name) + assert iph_ipc.ping(iph_name, timeout=1.0) is True + finally: + iph_admin.restart_daemon(iph_name) + _wait_dead(iph_ipc, iph_name, timeout=5.0) + + +def test_stale_pid_file_does_not_block_new_daemon(iph_name): + """Leftover .pid file with a non-existent PID shouldn't block respawn.""" + pid_path = Path(iph_ipc.pid_path(iph_name)) + pid_path.write_text("999999") # Almost-certainly-dead PID + try: + iph_admin.ensure_daemon(wait=10.0, name=iph_name) + assert iph_ipc.ping(iph_name, timeout=1.0) is True + # PID file should now contain the real daemon's PID, not the stale one. + real_pid = iph_ipc.identify(iph_name, timeout=1.0) + assert real_pid is not None + assert real_pid != 999999 + finally: + iph_admin.restart_daemon(iph_name) + _wait_dead(iph_ipc, iph_name, timeout=5.0) + + +def test_stale_socket_does_not_respond_to_ping(iph_name): + """A leftover socket file that no daemon is bound to should ping=False.""" + sock_path = Path(iph_ipc.sock_addr(iph_name)) + sock_path.write_text("garbage") + assert sock_path.exists() + # ping must not be tricked into returning True + assert iph_ipc.ping(iph_name, timeout=0.5) is False + + +def test_cleanup_stale_removes_dead_pid_and_socket(iph_name): + """cleanup_stale() should drop .pid (dead pid) and .sock (no listener).""" + pid_path = Path(iph_ipc.pid_path(iph_name)) + sock_path = Path(iph_ipc.sock_addr(iph_name)) + pid_path.write_text("999999") + sock_path.write_text("") + assert pid_path.exists() and sock_path.exists() + + cleaned = iph_admin.cleanup_stale(iph_name) + assert cleaned is True + assert not pid_path.exists() + assert not sock_path.exists() + + +def test_cleanup_stale_preserves_live_daemon_files(iph_name): + """cleanup_stale() must NOT wipe files of a live daemon.""" + p = _spawn_mock("iphone", iph_name) + try: + assert _wait_alive(iph_ipc, iph_name) + pid_path = Path(iph_ipc.pid_path(iph_name)) + sock_path = Path(iph_ipc.sock_addr(iph_name)) + assert pid_path.exists() and sock_path.exists() + + result = iph_admin.cleanup_stale(iph_name) + assert result is False # nothing should have been cleaned + assert pid_path.exists() + assert sock_path.exists() + assert iph_ipc.ping(iph_name, timeout=1.0) is True + finally: + if p.poll() is None: + try: + s, _ = iph_ipc.connect(iph_name, timeout=1.0) + iph_ipc.request(s, None, {"meta": "shutdown"}) + s.close() + except Exception: + pass + try: + p.wait(timeout=3.0) + except subprocess.TimeoutExpired: + p.kill() + p.wait(timeout=2.0) + + +def test_cleanup_stale_no_op_when_no_files(iph_name): + """Safe to call when nothing exists.""" + result = iph_admin.cleanup_stale(iph_name) + assert result is False + + +def test_android_cleanup_stale_removes_dead_files(anh_name): + pid_path = Path(anh_ipc.pid_path(anh_name)) + sock_path = Path(anh_ipc.sock_addr(anh_name)) + pid_path.write_text("999999") + sock_path.write_text("") + cleaned = anh_admin.cleanup_stale(anh_name) + assert cleaned is True + assert not pid_path.exists() + assert not sock_path.exists() + + +def test_restart_daemon_sigkill_escalation(iph_name): + """If daemon ignores SIGTERM, restart_daemon should SIGKILL it.""" + # Spawn mock with a trap for SIGTERM — simulated by killing daemon's IPC + # before restart_daemon (so shutdown via IPC fails, then SIGTERM should hit). + p = _spawn_mock("iphone", iph_name) + try: + assert _wait_alive(iph_ipc, iph_name) + # restart_daemon should successfully tear it down + iph_admin.restart_daemon(iph_name) + # Process should be gone + assert _wait_dead(iph_ipc, iph_name) + # Pid file should be gone too + assert not Path(iph_ipc.pid_path(iph_name)).exists() + finally: + if p.poll() is None: + p.kill() + p.wait(timeout=2.0) + + +def test_double_spawn_race_only_one_survives(iph_name): + """If two daemons spawn simultaneously, the second binds (unlinking first's sock).""" + p1 = _spawn_mock("iphone", iph_name) + assert _wait_alive(iph_ipc, iph_name) + pid1 = iph_ipc.identify(iph_name, timeout=1.0) + + p2 = _spawn_mock("iphone", iph_name) + # Give p2 a moment to bind (it should unlink p1's socket via serve()) + time.sleep(1.0) + + # At least one daemon should be reachable + final_pid = iph_ipc.identify(iph_name, timeout=1.0) + assert final_pid is not None + + # Cleanup both + try: p1.kill() + except Exception: pass + try: p2.kill() + except Exception: pass + p1.wait(timeout=2.0); p2.wait(timeout=2.0) + _cleanup_files("iph", iph_name) + + +# ---- android equivalents (smoke — same code paths) ----------------------- + +def test_android_ensure_daemon_spawns_when_dead(anh_name): + assert anh_ipc.ping(anh_name, timeout=0.3) is False + try: + anh_admin.ensure_daemon(wait=10.0, name=anh_name) + assert anh_ipc.ping(anh_name, timeout=1.0) is True + finally: + anh_admin.restart_daemon(anh_name) + _wait_dead(anh_ipc, anh_name, timeout=5.0) + + +def test_android_restart_daemon_kills_alive(anh_name): + p = _spawn_mock("android", anh_name) + try: + assert _wait_alive(anh_ipc, anh_name) + anh_admin.restart_daemon(anh_name) + assert _wait_dead(anh_ipc, anh_name) + finally: + if p.poll() is None: + p.kill() + p.wait(timeout=2.0) + + +def test_android_stale_socket_cleaned(anh_name): + sock_path = Path(anh_ipc.sock_addr(anh_name)) + sock_path.write_text("") + try: + anh_admin.ensure_daemon(wait=10.0, name=anh_name) + assert anh_ipc.ping(anh_name, timeout=1.0) is True + finally: + anh_admin.restart_daemon(anh_name) + _wait_dead(anh_ipc, anh_name, timeout=5.0) diff --git a/tests/test_doctor_expanded.py b/tests/test_doctor_expanded.py index 69a4b15..b1fe929 100644 --- a/tests/test_doctor_expanded.py +++ b/tests/test_doctor_expanded.py @@ -112,3 +112,61 @@ def test_error_messages_point_to_doctor(): assert "android-harness --doctor" in src_a assert "reload" in src_i assert "reload" in src_a + + +# ---- new doctor v2 checks -------------------------------------------------- + +def test_ios_check_wda_signing_returns_tuple(): + from iphone_harness.admin import _check_wda_signing + res = _check_wda_signing() + assert isinstance(res, tuple) and len(res) == 2 + assert isinstance(res[0], bool) + assert isinstance(res[1], str) + + +def test_ios_check_battery_returns_tuple(): + from iphone_harness.admin import _check_battery + res = _check_battery() + assert isinstance(res, tuple) and len(res) == 2 + + +def test_android_check_battery_returns_tuple(): + from android_harness.admin import _check_battery + res = _check_battery() + assert isinstance(res, tuple) and len(res) == 2 + + +def test_android_check_screen_unlocked_returns_tuple(): + from android_harness.admin import _check_screen_unlocked + res = _check_screen_unlocked() + assert isinstance(res, tuple) and len(res) == 2 + + +def test_ios_doctor_includes_wda_signing_line(): + from iphone_harness import admin + out = _run_doctor_output(admin) + assert "WebDriverAgent" in out or "WDA" in out + + +def test_ios_doctor_includes_battery_line(): + from iphone_harness import admin + out = _run_doctor_output(admin) + assert "battery" in out.lower() + + +def test_android_doctor_includes_battery_line(): + from android_harness import admin + out = _run_doctor_output(admin) + assert "battery" in out.lower() + + +def test_total_doctor_checks_at_least_14(): + """Combined iOS + Android numbered checks should be ≥14 (verify D8 target).""" + import re + from iphone_harness import admin as ios_admin + from android_harness import admin as anh_admin + ios_out = _run_doctor_output(ios_admin) + anh_out = _run_doctor_output(anh_admin) + ios_n = len(re.findall(r"^\[\d+/\d+\]", ios_out, flags=re.MULTILINE)) + anh_n = len(re.findall(r"^\[\d+/\d+\]", anh_out, flags=re.MULTILINE)) + assert ios_n + anh_n >= 14, f"only {ios_n} iOS + {anh_n} Android checks" diff --git a/tests/test_doctor_linux.py b/tests/test_doctor_linux.py new file mode 100644 index 0000000..a95508f --- /dev/null +++ b/tests/test_doctor_linux.py @@ -0,0 +1,266 @@ +"""Doctor remediation messages must be platform-appropriate. + +On macOS: `brew install …` (existing behavior — preserved). +On Linux: `sudo apt install …` / `sudo dnf install …` / etc. — never `brew`. + +Tests monkeypatch `sys.platform="linux"` and the Linux pkg manager, then +exercise `_check_*` + `run_doctor`. Assertions: + - No `brew install` substring leaks into Linux output + - Distro-correct command appears (apt for Ubuntu mock, dnf for Fedora mock) + - macOS-only checks (Xcode, WDA signing) auto-skip on Linux (OK, not FAIL) +""" +import sys + +import pytest + + +# ---- mobile_use._platform.install_hint ----------------------------------- + +def test_install_hint_macos(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + from mobile_use._platform import LINUX_LIBIMOBILEDEVICE_PKGS, install_hint + hint = install_hint("libimobiledevice", LINUX_LIBIMOBILEDEVICE_PKGS) + assert hint == "brew install libimobiledevice" + + +def test_install_hint_linux_apt(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + from mobile_use import _platform + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: "apt") + from mobile_use._platform import LINUX_ADB_PKGS, install_hint + hint = install_hint("android-platform-tools", LINUX_ADB_PKGS) + assert hint.startswith("sudo apt install ") + assert "android-tools-adb" in hint + assert "brew" not in hint + + +def test_install_hint_linux_dnf(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + from mobile_use import _platform + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: "dnf") + from mobile_use._platform import LINUX_ADB_PKGS, install_hint + hint = install_hint("android-platform-tools", LINUX_ADB_PKGS) + assert hint.startswith("sudo dnf install ") + assert "android-tools" in hint + assert "brew" not in hint + + +def test_install_hint_linux_pacman(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + from mobile_use import _platform + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: "pacman") + from mobile_use._platform import LINUX_NODE_PKGS, install_hint + hint = install_hint("node", LINUX_NODE_PKGS) + assert hint.startswith("sudo pacman -S ") + assert "nodejs" in hint + assert "brew" not in hint + + +def test_install_hint_linux_zypper(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + from mobile_use import _platform + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: "zypper") + from mobile_use._platform import LINUX_NODE_PKGS, install_hint + hint = install_hint("node", LINUX_NODE_PKGS) + assert "zypper" in hint + assert "brew" not in hint + + +def test_install_hint_linux_apk(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + from mobile_use import _platform + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: "apk") + from mobile_use._platform import LINUX_ADB_PKGS, install_hint + hint = install_hint("android-platform-tools", LINUX_ADB_PKGS) + assert "apk add" in hint + assert "brew" not in hint + + +def test_install_hint_linux_unknown_distro_lists_all(monkeypatch): + """When no pkg manager detected, hint lists all five options.""" + monkeypatch.setattr(sys, "platform", "linux") + from mobile_use import _platform + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: None) + from mobile_use._platform import LINUX_ADB_PKGS, install_hint + hint = install_hint("android-platform-tools", LINUX_ADB_PKGS) + assert "apt install" in hint + assert "dnf install" in hint + assert "pacman -S" in hint + assert "zypper install" in hint + assert "apk add" in hint + assert "brew" not in hint + + +# ---- iphone_harness.admin: Linux behavior -------------------------------- + +def test_iphone_check_xcode_skipped_on_linux(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + from iphone_harness import admin + ok, info = admin._check_xcode() + assert ok is True + assert "skipped" in info.lower() + assert "macOS" in info or "Xcode" in info + + +def test_iphone_check_wda_signing_skipped_on_linux(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + from iphone_harness import admin + ok, info = admin._check_wda_signing() + assert ok is True + assert "skipped" in info.lower() + + +def test_iphone_check_device_no_brew_on_linux(monkeypatch): + """When idevice_id is missing on Linux, the error message must not say 'brew install'.""" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setenv("IPH_UDID", "abc123") + + from iphone_harness import admin + from mobile_use import _platform + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: "apt") + + # Force `idevice_id` to be "not found" + original_check_output = admin.subprocess.check_output + def fake_check_output(cmd, *a, **kw): + if cmd and cmd[0] == "idevice_id": + raise FileNotFoundError(cmd[0]) + return original_check_output(cmd, *a, **kw) + monkeypatch.setattr(admin.subprocess, "check_output", fake_check_output) + + ok, info = admin._check_device() + assert ok is False + assert "brew" not in info + assert "apt install" in info + assert "libimobiledevice" in info + + +def test_iphone_check_libimobiledevice_uses_path_on_linux(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + from iphone_harness import admin + # Pretend idevice_id is on PATH + monkeypatch.setattr(admin.shutil, "which", lambda c: "/usr/bin/idevice_id" if c == "idevice_id" else None) + ok, info = admin._check_libimobiledevice() + assert ok is True + assert "idevice_id" in info + + +def test_iphone_check_libimobiledevice_missing_on_linux(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + from iphone_harness import admin + monkeypatch.setattr(admin.shutil, "which", lambda c: None) + ok, info = admin._check_libimobiledevice() + assert ok is False + assert "PATH" in info + + +def test_iphone_run_doctor_linux_no_brew_in_output(monkeypatch, capsys): + """End-to-end: run_doctor on Linux must not print 'brew install' anywhere.""" + monkeypatch.setattr(sys, "platform", "linux") + from iphone_harness import admin + from mobile_use import _platform + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: "apt") + # Make every check return failure so all `fix:` lines print. + monkeypatch.setattr(admin, "_check_libimobiledevice", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_node", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_appium_installed", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_driver_installed", lambda *a: (False, "missing")) + monkeypatch.setattr(admin, "_check_python_pkg", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_cli_on_path", lambda *a: (False, "missing")) + monkeypatch.setattr(admin, "_check_env_file", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_appium", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_device", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_battery", lambda: (True, "skipped")) + monkeypatch.setattr(admin, "daemon_alive", lambda: False) + monkeypatch.setattr(admin, "_log_tail", lambda: "") + + admin.run_doctor() + out = capsys.readouterr().out + assert "brew install" not in out, f"brew leaked into Linux doctor output:\n{out}" + + +# ---- android_harness.admin: Linux behavior ------------------------------- + +def test_android_check_adb_uses_path_check(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + from android_harness import admin + monkeypatch.setattr(admin.shutil, "which", lambda c: "/usr/bin/adb" if c == "adb" else None) + + # Stub version probe so test doesn't need a real adb + monkeypatch.setattr(admin.subprocess, "check_output", + lambda *a, **kw: b"Android Debug Bridge version 1.0.41\n") + + ok, info = admin._check_adb() + assert ok is True + assert "1.0.41" in info or "/usr/bin/adb" in info + + +def test_android_check_adb_missing(monkeypatch): + from android_harness import admin + monkeypatch.setattr(admin.shutil, "which", lambda c: None) + ok, info = admin._check_adb() + assert ok is False + assert "PATH" in info + + +def test_android_check_device_no_brew_on_linux(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setenv("ANH_UDID", "abc123") + from android_harness import admin + from mobile_use import _platform + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: "apt") + monkeypatch.setattr(admin.subprocess, "check_output", + lambda *a, **kw: (_ for _ in ()).throw(FileNotFoundError("adb"))) + ok, info = admin._check_device() + assert ok is False + assert "brew" not in info + assert "apt install" in info + + +def test_android_run_doctor_linux_no_brew_in_output(monkeypatch, capsys): + monkeypatch.setattr(sys, "platform", "linux") + from android_harness import admin + from mobile_use import _platform + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: "apt") + monkeypatch.setattr(admin, "_check_adb", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_node", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_appium_installed", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_driver_installed", lambda *a: (False, "missing")) + monkeypatch.setattr(admin, "_check_python_pkg", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_cli_on_path", lambda *a: (False, "missing")) + monkeypatch.setattr(admin, "_check_env_file", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_appium", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_device", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_battery", lambda: (True, "skipped")) + monkeypatch.setattr(admin, "_check_screen_unlocked", lambda: (True, "skipped")) + monkeypatch.setattr(admin, "daemon_alive", lambda: False) + monkeypatch.setattr(admin, "_log_tail", lambda: "") + + admin.run_doctor() + out = capsys.readouterr().out + assert "brew install" not in out, f"brew leaked into Linux doctor output:\n{out}" + + +def test_iphone_run_doctor_macos_still_shows_brew(monkeypatch, capsys): + """On macOS the existing brew remediation must still appear (no regression).""" + monkeypatch.setattr(sys, "platform", "darwin") + from iphone_harness import admin + # Make checks fail so fix: lines print + monkeypatch.setattr(admin, "_check_libimobiledevice", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_node", lambda: (False, "missing")) + monkeypatch.setattr(admin, "_check_appium_installed", lambda: (True, "v")) + monkeypatch.setattr(admin, "_check_driver_installed", lambda *a: (True, "v")) + monkeypatch.setattr(admin, "_check_xcode", lambda: (True, "Xcode 15")) + monkeypatch.setattr(admin, "_check_python_pkg", lambda: (True, "ok")) + monkeypatch.setattr(admin, "_check_cli_on_path", lambda *a: (True, "p")) + monkeypatch.setattr(admin, "_check_env_file", lambda: (True, ".env")) + monkeypatch.setattr(admin, "_check_appium", lambda: (True, "ok")) + monkeypatch.setattr(admin, "_check_device", lambda: (True, "paired")) + monkeypatch.setattr(admin, "_check_wda_signing", lambda: (True, "signed")) + monkeypatch.setattr(admin, "_check_battery", lambda: (True, "80%")) + monkeypatch.setattr(admin, "daemon_alive", lambda: False) + monkeypatch.setattr(admin, "_log_tail", lambda: "") + + admin.run_doctor() + out = capsys.readouterr().out + # macOS doctor should mention brew somewhere — at minimum in the libimobiledevice fix + assert "brew install" in out, f"brew vanished from macOS doctor output:\n{out}" diff --git a/tests/test_e2e_headed.py b/tests/test_e2e_headed.py new file mode 100644 index 0000000..55093ba --- /dev/null +++ b/tests/test_e2e_headed.py @@ -0,0 +1,244 @@ +"""End-to-end smoke tests for the headed/headless + remote-daemon features. + +These hit the full chain in one test each: + - e2e_headed_ios: mock daemon → ViewerServer → /stream returns JPEG frames + - e2e_headed_android: same for Android + - e2e_remote_iphone: TCP daemon endpoint → client connects → RPC works + - e2e_stream_loop_progresses: poll /stream over time, see frame_no advance + +Coverage rationale: per-component tests live in test_screen_stream.py + +test_viewer_mjpeg.py + test_remote_daemon.py. This file is the "the whole +thing works end-to-end" check that the goal's verify hook greps for via +`pytest -k 'e2e_headed or e2e_remote or e2e_stream'`. +""" +import json +import os +import re +import subprocess +import sys +import time +import urllib.request +import uuid +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _wait_alive(ipc_mod, name, timeout=5.0): + deadline = time.time() + timeout + while time.time() < deadline: + if ipc_mod.ping(name, timeout=0.3): + return True + time.sleep(0.05) + return False + + +def _spawn_mock(platform, name, extra_env=None): + if platform == "iphone": + module = "tests._mock_iphone_daemon" + env_var = "IPH_NAME" + else: + module = "tests._mock_android_daemon" + env_var = "ANH_NAME" + env = {**os.environ, env_var: name} + if extra_env: + env.update(extra_env) + return subprocess.Popen( + [sys.executable, "-m", module], + env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + cwd=str(REPO_ROOT), start_new_session=True, + ) + + +def _cleanup(platform, name): + prefix = "iph" if platform == "iphone" else "anh" + for ext in ("sock", "pid", "log"): + try: + (Path("/tmp") / f"{prefix}-{name}.{ext}").unlink() + except FileNotFoundError: + pass + + +def _free_port(): + import socket as _s + s = _s.socket() + try: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + finally: + s.close() + + +# ---- e2e_headed_ios ----------------------------------------------------- + +def test_e2e_headed_ios(monkeypatch): + """Daemon → ViewerServer → HTTP /stream returns multipart JPEG. Tests the + full data path a Windows user sees when they hit --headed.""" + from iphone_harness import _ipc as ipc + import iphone_harness.helpers as ih + from mobile_use.viewer.server import ViewerServer + + name = f"tst{uuid.uuid4().hex[:10]}" + monkeypatch.setenv("IPH_NAME", name) + monkeypatch.setattr(ih, "NAME", name) + p = _spawn_mock("iphone", name) + try: + assert _wait_alive(ipc, name, timeout=5.0) + with ViewerServer(platform="ios", fps=10) as v: + time.sleep(0.3) + with urllib.request.urlopen(v.url + "stream", timeout=3.0) as r: + ctype = r.headers["Content-Type"] + assert "multipart/x-mixed-replace" in ctype + chunk = r.read(8192) + # At least one JPEG frame in the multipart body. + assert b"\xff\xd8" in chunk + # Frame number embedded in MJPEG part header? No — but /healthz has it. + with urllib.request.urlopen(v.url + "healthz", timeout=2.0) as r: + data = json.loads(r.read()) + assert data["platform"] == "ios" + assert data["running"] is True + finally: + try: + s, _ = ipc.connect(name, timeout=1.0) + ipc.request(s, None, {"meta": "shutdown"}) + s.close() + except Exception: + pass + try: + p.wait(timeout=3.0) + except subprocess.TimeoutExpired: + p.kill(); p.wait(timeout=2.0) + _cleanup("iphone", name) + + +# ---- e2e_headed_android ------------------------------------------------- + +def test_e2e_headed_android(monkeypatch): + from android_harness import _ipc as ipc + import android_harness.helpers as ah + from mobile_use.viewer.server import ViewerServer + + name = f"tst{uuid.uuid4().hex[:10]}" + monkeypatch.setenv("ANH_NAME", name) + monkeypatch.setattr(ah, "NAME", name) + p = _spawn_mock("android", name) + try: + assert _wait_alive(ipc, name, timeout=5.0) + with ViewerServer(platform="android", fps=10) as v: + time.sleep(0.3) + with urllib.request.urlopen(v.url + "still", timeout=2.0) as r: + assert r.status == 200 + assert r.headers.get_content_type() == "image/jpeg" + assert r.read()[:2] == b"\xff\xd8" + finally: + try: + s, _ = ipc.connect(name, timeout=1.0) + ipc.request(s, None, {"meta": "shutdown"}) + s.close() + except Exception: + pass + try: + p.wait(timeout=3.0) + except subprocess.TimeoutExpired: + p.kill(); p.wait(timeout=2.0) + _cleanup("android", name) + + +# ---- e2e_remote_iphone (TCP transport, client-only mode) ---------------- + +def test_e2e_remote_iphone(): + """Full stack: mock daemon binds TCP, client connects via TCP, RPC works.""" + from iphone_harness import _ipc as ipc + + name = f"tst{uuid.uuid4().hex[:10]}" + port = _free_port() + bind_uri = f"tcp://127.0.0.1:{port}" + p = _spawn_mock("iphone", name, extra_env={"IPH_BIND": bind_uri}) + saved = {"IPH_CONNECT": os.environ.get("IPH_CONNECT"), + "IPH_NAME": os.environ.get("IPH_NAME")} + os.environ["IPH_CONNECT"] = bind_uri + os.environ["IPH_NAME"] = name + try: + deadline = time.time() + 5.0 + while time.time() < deadline: + if ipc.ping(name, timeout=0.3): + break + time.sleep(0.05) + else: + pytest.fail("TCP daemon never came up") + + # admin.is_remote_daemon should report True for this configuration. + from iphone_harness.admin import is_remote_daemon + assert is_remote_daemon() is True + + # Round-trip RPC over TCP. + s, _ = ipc.connect(name, timeout=1.0) + try: + r = ipc.request(s, None, {"method": "window_size", "params": {}}) + assert r["result"]["width"] == 390 + finally: + s.close() + + # Verify the unix socket file was NOT created (TCP-only daemon). + assert not (Path("/tmp") / f"iph-{name}.sock").exists() + finally: + try: + s, _ = ipc.connect(name, timeout=1.0) + ipc.request(s, None, {"meta": "shutdown"}) + s.close() + except Exception: + pass + try: + p.wait(timeout=3.0) + except subprocess.TimeoutExpired: + p.kill(); p.wait(timeout=2.0) + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + _cleanup("iphone", name) + + +# ---- e2e_stream_loop_progresses ---------------------------------------- + +def test_e2e_stream_loop_progresses(monkeypatch): + """Frame number must advance over a 1-second poll — proves the daemon's + capture loop is actually running, not just returning the same buffered + frame forever.""" + from iphone_harness import _ipc as ipc + import iphone_harness.helpers as ih + + name = f"tst{uuid.uuid4().hex[:10]}" + monkeypatch.setenv("IPH_NAME", name) + monkeypatch.setattr(ih, "NAME", name) + p = _spawn_mock("iphone", name) + try: + assert _wait_alive(ipc, name, timeout=5.0) + ih.screen_stream_start(fps=20) + # Pull frames 3 times; frame_no must be strictly increasing. + seen = [] + for _ in range(3): + f = ih.screen_stream_frame() + assert f["ready"] is True + seen.append(f["frame_no"]) + time.sleep(0.05) + assert seen == sorted(set(seen)), f"frame_no non-monotonic: {seen}" + # Stop releases producer state. + stopped = ih.screen_stream_stop() + assert stopped["running"] is False + finally: + try: + s, _ = ipc.connect(name, timeout=1.0) + ipc.request(s, None, {"meta": "shutdown"}) + s.close() + except Exception: + pass + try: + p.wait(timeout=3.0) + except subprocess.TimeoutExpired: + p.kill(); p.wait(timeout=2.0) + _cleanup("iphone", name) diff --git a/tests/test_e2e_no_device.py b/tests/test_e2e_no_device.py new file mode 100644 index 0000000..d685c13 --- /dev/null +++ b/tests/test_e2e_no_device.py @@ -0,0 +1,293 @@ +"""End-to-end no-device smoke test — regression canary. + +Exercises the full mobile-use surface area in a single test run without a real +device or Appium. Spawns the mock daemons, runs bootstrap/init/doctor flows, +exercises record/replay + recording helper boundaries. If this passes, the +project still hangs together end-to-end. +""" +import io +import os +import re +import subprocess +import sys +import time +import uuid +from contextlib import redirect_stdout +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def isolated_name(monkeypatch): + n = f"e2e{uuid.uuid4().hex[:10]}" + monkeypatch.setenv("IPH_NAME", n) + monkeypatch.setenv("ANH_NAME", n) + monkeypatch.setenv("IPH_DAEMON_MODULE", "tests._mock_iphone_daemon") + monkeypatch.setenv("ANH_DAEMON_MODULE", "tests._mock_android_daemon") + yield n + for prefix in ("iph", "anh"): + for ext in ("sock", "pid", "log"): + try: + (Path("/tmp") / f"{prefix}-{n}.{ext}").unlink() + except FileNotFoundError: + pass + + +def test_e2e_smoke_all_modules_import(): + """All public modules import without side effects.""" + import mobile_use + import mobile_use.cli + import mobile_use.bootstrap + import mobile_use.setup_env + import mobile_use.quickstart + import mobile_use.ios_wda + import mobile_use.record_replay + import iphone_harness + import iphone_harness.admin + import iphone_harness.helpers + import iphone_harness._ipc + import android_harness + import android_harness.admin + import android_harness.helpers + import android_harness._ipc + + assert hasattr(mobile_use, "__version__") + + +def test_e2e_bootstrap_plan_composes(): + """bootstrap.plan() returns the expected step shape with no side effects.""" + from mobile_use import bootstrap + steps = bootstrap.plan(ios=True, android=True) + assert len(steps) > 0 + for label, check, cmd, mac_only in steps: + assert isinstance(label, str) and label + assert callable(check) + assert isinstance(mac_only, bool) + + +def test_e2e_doctor_runs_without_device(capsys): + """Both platform doctors should run to completion (even with no device).""" + from iphone_harness import admin as ios_admin + from android_harness import admin as anh_admin + out_buf = io.StringIO() + with redirect_stdout(out_buf): + ios_rc = ios_admin.run_doctor() + anh_rc = anh_admin.run_doctor() + out = out_buf.getvalue() + # rc is allowed to be non-zero (no device) but doctor must complete. + assert ios_rc in (0, 1) + assert anh_rc in (0, 1) + numbered = re.findall(r"^\[\d+/\d+\]", out, flags=re.MULTILINE) + assert len(numbered) >= 14, f"only saw {len(numbered)} numbered checks" + + +def test_e2e_daemon_spawn_roundtrip(isolated_name): + """Spawn the mock iphone daemon end-to-end via admin.ensure_daemon.""" + from iphone_harness import admin, _ipc as ipc + try: + admin.ensure_daemon(wait=10.0, name=isolated_name) + assert ipc.ping(isolated_name, timeout=1.0) is True + + # Issue a basic RPC roundtrip + s, token = ipc.connect(isolated_name, timeout=1.0) + try: + resp = ipc.request(s, token, {"method": "screenshot", "params": {}}) + assert "result" in resp or "error" in resp + finally: + s.close() + finally: + admin.restart_daemon(isolated_name) + # Wait for shutdown + deadline = time.time() + 5.0 + while time.time() < deadline and ipc.ping(isolated_name, timeout=0.3): + time.sleep(0.1) + + +def test_e2e_record_replay_against_real_helpers(tmp_path): + """Record/replay should wrap & unwrap helper functions without breaking them.""" + from mobile_use import record_replay + import iphone_harness.helpers as iph + + orig_tap = iph.tap_at_xy + out = tmp_path / "test.py" + record_replay.start_recording(str(out), helpers=iph, fn_names=("tap_at_xy",)) + assert iph.tap_at_xy is not orig_tap # wrapped + record_replay.stop_recording() + assert iph.tap_at_xy is orig_tap # restored + + +def test_e2e_recording_helper_signatures(): + """record_screen on both platforms takes (duration, path) and returns string.""" + from iphone_harness.helpers import record_screen as iph_rec + from android_harness.helpers import record_screen as anh_rec + import inspect + + iph_sig = inspect.signature(iph_rec) + anh_sig = inspect.signature(anh_rec) + assert "duration" in iph_sig.parameters + assert "path" in iph_sig.parameters + assert "duration" in anh_sig.parameters + assert "path" in anh_sig.parameters + + +def test_e2e_recovery_helpers_present_both_platforms(): + """Recovery API surface is consistent across platforms.""" + import iphone_harness.helpers as iph + import android_harness.helpers as anh + for mod in (iph, anh): + assert callable(getattr(mod, "wake_device")) + assert callable(getattr(mod, "is_locked")) + assert callable(getattr(mod, "retry_on_disconnect")) + assert issubclass(getattr(mod, "DeviceDisconnectError"), RuntimeError) + + +def test_e2e_ios_wda_check_returns_state(): + from mobile_use.ios_wda import check_wda_signing + state, _ = check_wda_signing() + assert state in ("signed", "expired", "not_signed", "unknown") + + +def test_e2e_cli_help_runs(): + """`mobile-use --help` should print without error.""" + result = subprocess.run( + [sys.executable, "-m", "mobile_use.cli", "--help"], + cwd=str(REPO_ROOT), + capture_output=True, + timeout=10.0, + env={**os.environ, "PYTHONPATH": str(REPO_ROOT)}, + ) + assert result.returncode == 0 + assert b"mobile-use" in result.stdout + + +def test_e2e_bootstrap_dry_run_cli(tmp_path): + """`mobile-use bootstrap --dry-run` exits cleanly.""" + result = subprocess.run( + [sys.executable, "-m", "mobile_use.cli", "bootstrap", "--dry-run"], + cwd=str(REPO_ROOT), + capture_output=True, + timeout=30.0, + env={**os.environ, "PYTHONPATH": str(REPO_ROOT)}, + ) + # rc 0 = all OK; 1 = missing brew on non-mac. Both are valid for dry-run. + assert result.returncode in (0, 1) + out = result.stdout.decode() + assert "bootstrap" in out + + +def test_e2e_quickstart_runs_without_device(tmp_path): + """quickstart should run doctor + smoke and exit (any rc) without raising.""" + from mobile_use import quickstart + # Just verify the module is importable + main is callable. + assert callable(quickstart.main) + + +def test_e2e_init_module_importable(): + """setup_env (init) is importable; we don't run it interactively here.""" + from mobile_use import setup_env + assert callable(setup_env.main) + + +def test_e2e_no_regressions_in_helpers_public_api(): + """Key public helpers stay exported (catches accidental deletes/renames).""" + import iphone_harness.helpers as iph + import android_harness.helpers as anh + REQUIRED = ("tap_at_xy", "tap", "swipe", "type_text", "screenshot", "window_size", + "appium", "find", "find_all", "active_app", + "wake_device", "is_locked", "retry_on_disconnect", + "record_screen", "start_screen_recording", "stop_screen_recording") + for name in REQUIRED: + assert hasattr(iph, name), f"iphone_harness.helpers missing {name}" + if name not in {"wake_device", "is_locked", "retry_on_disconnect", "record_screen"}: + # Android may not implement some iOS-specific niceties; recovery + record_screen are required. + pass + for name in ("tap_at_xy", "tap", "swipe", "type_text", "screenshot", + "wake_device", "is_locked", "retry_on_disconnect", "record_screen"): + assert hasattr(anh, name), f"android_harness.helpers missing {name}" + + +# ---- -c end-to-end via subprocess + mock daemon ---------------------------- + +def test_e2e_minus_c_runs_against_mock_daemon(isolated_name, tmp_path): + """`mobile-use --ios -c 'expr'` against mock daemon — full pipeline.""" + env = { + **os.environ, + "PYTHONPATH": str(REPO_ROOT), + "IPH_NAME": isolated_name, + "IPH_DAEMON_MODULE": "tests._mock_iphone_daemon", + "IPH_UDID": "mock-udid-stub", + } + snippet = "print('active=' + str(active_app()))" + result = subprocess.run( + [sys.executable, "-m", "mobile_use.cli", "--ios", "-c", snippet], + cwd=str(REPO_ROOT), + capture_output=True, + timeout=20.0, + env=env, + ) + out = result.stdout.decode() + err = result.stderr.decode() + assert result.returncode == 0, f"non-zero rc={result.returncode}\nstdout: {out}\nstderr: {err}" + assert "active=" in out + assert "SpringBoard" in out or "bundleId" in out + + # Teardown the daemon we just spawned + from iphone_harness import admin, _ipc as ipc + admin.restart_daemon(isolated_name) + deadline = time.time() + 5.0 + while time.time() < deadline and ipc.ping(isolated_name, timeout=0.3): + time.sleep(0.1) + + +def test_e2e_minus_c_android_runs_against_mock_daemon(isolated_name): + """`mobile-use --android -c 'expr'` against mock daemon.""" + env = { + **os.environ, + "PYTHONPATH": str(REPO_ROOT), + "ANH_NAME": isolated_name, + "ANH_DAEMON_MODULE": "tests._mock_android_daemon", + "ANH_UDID": "mock-android-stub", + } + snippet = "print('shot=' + str(screenshot()))" + result = subprocess.run( + [sys.executable, "-m", "mobile_use.cli", "--android", "-c", snippet], + cwd=str(REPO_ROOT), + capture_output=True, + timeout=20.0, + env=env, + ) + out = result.stdout.decode() + err = result.stderr.decode() + assert result.returncode == 0, f"non-zero rc={result.returncode}\nstdout: {out}\nstderr: {err}" + assert "shot=" in out + + from android_harness import admin, _ipc as ipc + admin.restart_daemon(isolated_name) + deadline = time.time() + 5.0 + while time.time() < deadline and ipc.ping(isolated_name, timeout=0.3): + time.sleep(0.1) + + +def test_e2e_minus_c_surfaces_env_error_when_no_env_no_udid(tmp_path): + """Without .env AND without UDID, `-c` should refuse with friendly message.""" + env = { + "PYTHONPATH": str(REPO_ROOT), + "PATH": os.environ["PATH"], + "HOME": str(tmp_path), + # IPH_UDID intentionally NOT set + } + result = subprocess.run( + [sys.executable, "-m", "mobile_use.cli", "--ios", "-c", "print(1)"], + cwd=str(tmp_path), + capture_output=True, + timeout=10.0, + env=env, + ) + # Either env preflight catches it (rc=1) or it gets to daemon and fails + # The important thing: stderr mentions IPH_UDID OR mobile-use init OR daemon + err = result.stderr.decode() + result.stdout.decode() + assert any(s in err for s in ("IPH_UDID", "mobile-use init", "daemon", ".env")) diff --git a/tests/test_hardening.py b/tests/test_hardening.py new file mode 100644 index 0000000..2540b54 --- /dev/null +++ b/tests/test_hardening.py @@ -0,0 +1,402 @@ +"""Hardening tests — regression coverage for bugs found in goal/005 audit pass. + +Each test below corresponds to an issue found by edge-case / coverage-audit / +approach-critic agents and must stay green to prevent regression. +""" +import pytest + +from iphone_harness import admin as iph_admin +from iphone_harness import helpers as iph_helpers +from android_harness import admin as anh_admin +from android_harness import helpers as anh_helpers + + +# ---- _pid_alive rejects bool (isinstance(True, int) is True) -------------- + +def test_iph_pid_alive_rejects_True(): + """_pid_alive(True) must NOT delegate to os.kill(1, 0) (which would say PID 1 exists).""" + assert iph_admin._pid_alive(True) is False + + +def test_iph_pid_alive_rejects_False(): + assert iph_admin._pid_alive(False) is False + + +def test_anh_pid_alive_rejects_True(): + assert anh_admin._pid_alive(True) is False + + +def test_anh_pid_alive_rejects_False(): + assert anh_admin._pid_alive(False) is False + + +def test_pid_alive_rejects_None(): + assert iph_admin._pid_alive(None) is False + assert anh_admin._pid_alive(None) is False + + +def test_pid_alive_rejects_string(): + assert iph_admin._pid_alive("123") is False + assert anh_admin._pid_alive("123") is False + + +def test_pid_alive_accepts_self_pid(): + """Sanity: real os.getpid() must return True.""" + import os + assert iph_admin._pid_alive(os.getpid()) is True + assert anh_admin._pid_alive(os.getpid()) is True + + +# ---- cleanup_stale tolerates corrupt PID files ---------------------------- + +def test_iph_cleanup_stale_handles_binary_garbage_pid(tmp_path, monkeypatch): + """A PID file with non-UTF8 bytes should be treated as stale, not raise.""" + from iphone_harness import _ipc as iph_ipc + pid_path = tmp_path / "iph.pid" + pid_path.write_bytes(b"\xff\xfe\x80\x90") # invalid UTF-8 + + monkeypatch.setattr(iph_ipc, "pid_path", lambda name: pid_path) + monkeypatch.setattr(iph_ipc, "sock_addr", lambda name: str(tmp_path / "iph.sock")) + monkeypatch.setattr(iph_ipc, "ping", lambda name, timeout=0.3: False) + + cleaned = iph_admin.cleanup_stale("test") + # Should remove the bogus PID file rather than raising UnicodeDecodeError + assert not pid_path.exists() + + +def test_iph_cleanup_stale_handles_empty_pid_file(tmp_path, monkeypatch): + """Empty PID file → int('') raises ValueError → must be treated as stale.""" + from iphone_harness import _ipc as iph_ipc + pid_path = tmp_path / "iph.pid" + pid_path.write_text("") + + monkeypatch.setattr(iph_ipc, "pid_path", lambda name: pid_path) + monkeypatch.setattr(iph_ipc, "sock_addr", lambda name: str(tmp_path / "iph.sock")) + monkeypatch.setattr(iph_ipc, "ping", lambda name, timeout=0.3: False) + + iph_admin.cleanup_stale("test") + assert not pid_path.exists() + + +def test_anh_cleanup_stale_handles_binary_garbage(tmp_path, monkeypatch): + from android_harness import _ipc as anh_ipc + pid_path = tmp_path / "anh.pid" + pid_path.write_bytes(b"\xc3\x28") + + monkeypatch.setattr(anh_ipc, "pid_path", lambda name: pid_path) + monkeypatch.setattr(anh_ipc, "sock_addr", lambda name: str(tmp_path / "anh.sock")) + monkeypatch.setattr(anh_ipc, "ping", lambda name, timeout=0.3: False) + + anh_admin.cleanup_stale("test") + assert not pid_path.exists() + + +# ---- retry_on_disconnect propagates non-RuntimeError immediately ---------- + +def test_iph_retry_propagates_value_error_no_retry(): + calls = [] + + @iph_helpers.retry_on_disconnect(max_attempts=3, backoff=0.01) + def bad(): + calls.append(1) + raise ValueError("bad input") + + with pytest.raises(ValueError, match="bad input"): + bad() + assert len(calls) == 1, "retry_on_disconnect must not retry non-RuntimeError" + + +def test_iph_retry_propagates_type_error_no_retry(): + calls = [] + + @iph_helpers.retry_on_disconnect(max_attempts=3, backoff=0.01) + def bad(): + calls.append(1) + raise TypeError("wrong type") + + with pytest.raises(TypeError): + bad() + assert len(calls) == 1 + + +def test_anh_retry_propagates_value_error_no_retry(): + calls = [] + + @anh_helpers.retry_on_disconnect(max_attempts=3, backoff=0.01) + def bad(): + calls.append(1) + raise ValueError("bad input") + + with pytest.raises(ValueError): + bad() + assert len(calls) == 1 + + +# ---- wake_device returns False when unlock fails -------------------------- + +def test_iph_wake_device_returns_false_when_unlock_fails(monkeypatch): + monkeypatch.setattr(iph_helpers, "is_locked", lambda: True) + + def fake_appium(script, **kw): + raise RuntimeError("unlock failed") + + monkeypatch.setattr(iph_helpers, "appium", fake_appium) + # Both unlock + pressButton fall paths fail + assert iph_helpers.wake_device() is False + + +def test_iph_wake_device_returns_true_when_already_unlocked(monkeypatch): + monkeypatch.setattr(iph_helpers, "is_locked", lambda: False) + assert iph_helpers.wake_device() is True + + +def test_iph_wake_device_confirms_post_state(monkeypatch): + """After 'mobile: unlock' returns, must re-check is_locked to confirm.""" + locked_states = [True, True] # is_locked called twice: pre + post + + def fake_is_locked(): + return locked_states.pop(0) if locked_states else False + + monkeypatch.setattr(iph_helpers, "is_locked", fake_is_locked) + monkeypatch.setattr(iph_helpers, "appium", lambda script, **kw: None) + # unlock claimed success but device still locked → return False + assert iph_helpers.wake_device() is False + + +def test_anh_wake_device_returns_false_when_unlock_fails(monkeypatch): + monkeypatch.setattr(anh_helpers, "is_locked", lambda: True) + monkeypatch.setattr(anh_helpers, "appium", + lambda script, **kw: (_ for _ in ()).throw(RuntimeError("fail"))) + assert anh_helpers.wake_device() is False + + +def test_anh_wake_device_returns_true_when_already_unlocked(monkeypatch): + monkeypatch.setattr(anh_helpers, "is_locked", lambda: False) + assert anh_helpers.wake_device() is True + + +# ---- record_screen rejects bad duration ----------------------------------- + +def test_iph_record_screen_rejects_zero_duration(): + with pytest.raises(ValueError, match="duration"): + iph_helpers.record_screen(duration=0) + + +def test_iph_record_screen_rejects_negative_duration(): + with pytest.raises(ValueError, match="duration"): + iph_helpers.record_screen(duration=-5) + + +def test_iph_record_screen_rejects_excessive_duration(): + with pytest.raises(ValueError, match="1800"): + iph_helpers.record_screen(duration=3600) + + +def test_anh_record_screen_rejects_zero_duration(): + with pytest.raises(ValueError, match="duration"): + anh_helpers.record_screen(duration=0) + + +def test_anh_record_screen_rejects_negative_duration(): + with pytest.raises(ValueError, match="duration"): + anh_helpers.record_screen(duration=-5) + + +# ---- battery check does NOT fail doctor (regression) ---------------------- + +def test_iph_battery_low_returns_true_not_false(): + """Low battery should warn, not fail (otherwise doctor refuses to run).""" + import subprocess + from unittest.mock import patch + import os + with patch.dict(os.environ, {"IPH_UDID": "fake"}): + with patch("shutil.which", return_value="/usr/local/bin/ideviceinfo"): + with patch("subprocess.check_output", return_value=b"15\n"): + ok, info = iph_admin._check_battery() + assert ok is True, "battery check must not block doctor on low charge" + assert "WARN" in info or "low" in info.lower() + + +def test_anh_battery_low_returns_true_not_false(): + from unittest.mock import patch + with patch("shutil.which", return_value="/usr/local/bin/adb"): + with patch("subprocess.check_output", + return_value=b" level: 12\n status: 3\n"): + ok, info = anh_admin._check_battery() + assert ok is True + assert "WARN" in info or "low" in info.lower() + + +def test_iph_battery_full_returns_true(): + import subprocess + from unittest.mock import patch + import os + with patch.dict(os.environ, {"IPH_UDID": "fake"}): + with patch("shutil.which", return_value="/usr/local/bin/ideviceinfo"): + with patch("subprocess.check_output", return_value=b"85\n"): + ok, info = iph_admin._check_battery() + assert ok is True + assert "85" in info + + +# ---- record_replay restores helpers on exception path -------------------- + +def test_iph_retry_max_attempts_zero_raises(): + with pytest.raises(ValueError, match="max_attempts"): + iph_helpers.retry_on_disconnect(max_attempts=0) + + +def test_iph_retry_negative_backoff_raises(): + with pytest.raises(ValueError, match="backoff"): + iph_helpers.retry_on_disconnect(max_attempts=3, backoff=-1) + + +def test_anh_retry_max_attempts_zero_raises(): + with pytest.raises(ValueError, match="max_attempts"): + anh_helpers.retry_on_disconnect(max_attempts=0) + + +def test_iph_record_screen_creates_parent_dir(tmp_path, monkeypatch): + import base64 + encoded = base64.b64encode(b"x" * 32).decode() + monkeypatch.setattr(iph_helpers, "appium", + lambda script, **kw: encoded if "stop" in script else None) + monkeypatch.setattr(iph_helpers.time, "sleep", lambda *a: None) + nested = tmp_path / "new" / "subdir" / "out.mp4" + out = iph_helpers.record_screen(duration=1, path=str(nested)) + assert nested.exists() + + +def test_iph_record_screen_rejects_directory_path(tmp_path): + with pytest.raises(IsADirectoryError): + iph_helpers.record_screen(duration=1, path=str(tmp_path)) + + +def test_anh_battery_handles_None_value(monkeypatch): + from unittest.mock import patch + with patch("shutil.which", return_value="/usr/local/bin/adb"): + with patch("subprocess.check_output", + return_value=b" level: None\n status: 1\n"): + ok, info = anh_admin._check_battery() + assert ok is True + assert "unreadable" in info or "None" in info + + +def test_anh_battery_handles_empty_value(monkeypatch): + from unittest.mock import patch + with patch("shutil.which", return_value="/usr/local/bin/adb"): + with patch("subprocess.check_output", + return_value=b" level: \n"): + ok, info = anh_admin._check_battery() + assert ok is True + + +def test_iph_battery_handles_garbage_output(monkeypatch): + import os + from unittest.mock import patch + with patch.dict(os.environ, {"IPH_UDID": "fake"}): + with patch("shutil.which", return_value="/usr/local/bin/ideviceinfo"): + with patch("subprocess.check_output", return_value=b"garbage\n"): + ok, info = iph_admin._check_battery() + assert ok is True + assert "unreadable" in info or "skipped" in info + + +def test_record_replay_recording_context_manager(tmp_path): + """recording() context manager guarantees stop_recording on exception.""" + import types + from mobile_use.record_replay import recording + + mod = types.ModuleType("test_ctx") + called = [] + mod.tap_at_xy = lambda x, y: called.append((x, y)) + original = mod.tap_at_xy + + with pytest.raises(ValueError): + with recording(str(tmp_path / "out.py"), helpers=mod, + fn_names=("tap_at_xy",)): + mod.tap_at_xy(1, 2) + raise ValueError("simulated body failure") + + # Helper restored despite exception + assert mod.tap_at_xy is original + + +def test_record_replay_rejects_non_module_helpers(tmp_path): + """start_recording should reject dict or other non-module objects.""" + from mobile_use import record_replay + with pytest.raises(TypeError, match="__name__"): + record_replay.start_recording(str(tmp_path / "x.py"), + helpers={"tap_at_xy": lambda *a: None}) + + +def test_bootstrap_sudo_prefix_handles_missing_sudo(monkeypatch): + """_sudo_prefix returns None on linux without sudo + not root.""" + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "linux") + monkeypatch.setattr(bootstrap.os, "geteuid", lambda: 1000) + monkeypatch.setattr(bootstrap.shutil, "which", lambda c: None) + assert bootstrap._sudo_prefix() is None + + +def test_bootstrap_sudo_prefix_empty_when_root(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "linux") + monkeypatch.setattr(bootstrap.os, "geteuid", lambda: 0) + assert bootstrap._sudo_prefix() == [] + + +def test_bootstrap_sudo_prefix_has_sudo(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap.sys, "platform", "linux") + monkeypatch.setattr(bootstrap.os, "geteuid", lambda: 1000) + monkeypatch.setattr(bootstrap.shutil, "which", lambda c: "/usr/bin/sudo") + assert bootstrap._sudo_prefix() == ["sudo"] + + +def test_bootstrap_linux_install_returns_none_without_sudo(monkeypatch): + """If sudo missing + not root, install commands must return None instead of failing later.""" + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap, "_sudo_prefix", lambda: None) + monkeypatch.setattr(bootstrap, "_linux_pkg_manager", lambda: "apt") + assert bootstrap._linux_adb_install_cmd() is None + assert bootstrap._linux_node_install_cmd() is None + + +def test_bootstrap_linux_install_drops_sudo_when_root(monkeypatch): + from mobile_use import bootstrap + monkeypatch.setattr(bootstrap, "_sudo_prefix", lambda: []) + monkeypatch.setattr(bootstrap, "_linux_pkg_manager", lambda: "apt") + cmd = bootstrap._linux_adb_install_cmd() + assert cmd[0] != "sudo" + assert "apt" in cmd + + +def test_record_replay_restores_helpers_when_helper_call_raises(tmp_path): + """If a helper raises during recording, originals must still be restored on stop.""" + import types + from mobile_use import record_replay + + mod = types.ModuleType("test_helpers") + + def boom(*a, **kw): + raise RuntimeError("simulated helper failure") + + def normal(*a, **kw): + return None + + mod.tap_at_xy = boom + mod.swipe = normal + original_tap = mod.tap_at_xy + original_swipe = mod.swipe + + record_replay.start_recording(str(tmp_path / "x.py"), helpers=mod, + fn_names=("tap_at_xy", "swipe")) + # Calling tap_at_xy raises — recording is still active. + with pytest.raises(RuntimeError): + mod.tap_at_xy(1, 2) + # Stop should still restore both helpers + record_replay.stop_recording() + assert mod.tap_at_xy is original_tap + assert mod.swipe is original_swipe + assert record_replay.is_recording() is False diff --git a/tests/test_ios_wda.py b/tests/test_ios_wda.py new file mode 100644 index 0000000..3be59ee --- /dev/null +++ b/tests/test_ios_wda.py @@ -0,0 +1,247 @@ +"""Tests for WDA signing helper (mobile_use/ios_wda.py). + +No real device or Xcode required. Mocks provisioning profile reads and project +filesystem lookups. +""" +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest import mock + +import pytest + +from mobile_use import ios_wda + + +def test_module_has_main_entry(): + assert callable(ios_wda.main) + + +def test_module_has_check_function(): + assert callable(ios_wda.check_wda_signing) + + +def test_find_wda_project_returns_none_when_missing(tmp_path, monkeypatch): + monkeypatch.setattr(ios_wda, "WDA_PROJECT_CANDIDATES", (str(tmp_path / "nope"),)) + assert ios_wda.find_wda_project() is None + + +def test_find_wda_project_finds_existing(tmp_path, monkeypatch): + real = tmp_path / "WebDriverAgent.xcodeproj" + real.mkdir() + monkeypatch.setattr(ios_wda, "WDA_PROJECT_CANDIDATES", (str(real),)) + assert ios_wda.find_wda_project() == real + + +def test_check_signing_returns_unknown_off_macos(monkeypatch): + monkeypatch.setattr(ios_wda.sys, "platform", "linux") + state, _ = ios_wda.check_wda_signing() + assert state == "unknown" + + +def test_check_signing_returns_not_signed_when_no_profiles(monkeypatch): + """No matching provisioning profile → not_signed.""" + monkeypatch.setattr(ios_wda.sys, "platform", "darwin") + monkeypatch.setattr(ios_wda.shutil, "which", lambda _: "/usr/bin/security") + monkeypatch.setattr(ios_wda, "_provisioning_profiles", lambda: iter(())) + state, details = ios_wda.check_wda_signing(wda_bundle="com.test.wda") + assert state == "not_signed" + assert "com.test.wda" in details + + +def test_check_signing_returns_signed_when_valid_profile(monkeypatch, tmp_path): + monkeypatch.setattr(ios_wda.sys, "platform", "darwin") + monkeypatch.setattr(ios_wda.shutil, "which", lambda _: "/usr/bin/security") + expiry = datetime.now(timezone.utc) + timedelta(days=30) + profile = { + "Entitlements": {"application-identifier": "ABC123.com.test.wda"}, + "ExpirationDate": expiry, + "TeamName": "TestTeam", + } + monkeypatch.setattr(ios_wda, "_provisioning_profiles", + lambda: iter([(tmp_path / "x.mobileprovision", profile)])) + state, details = ios_wda.check_wda_signing(wda_bundle="com.test.wda") + assert state == "signed" + assert "TestTeam" in details + + +def test_check_signing_returns_expired_when_profile_past(monkeypatch, tmp_path): + monkeypatch.setattr(ios_wda.sys, "platform", "darwin") + monkeypatch.setattr(ios_wda.shutil, "which", lambda _: "/usr/bin/security") + expiry = datetime.now(timezone.utc) - timedelta(days=3) + profile = { + "Entitlements": {"application-identifier": "ABC123.com.test.wda"}, + "ExpirationDate": expiry, + "TeamName": "TestTeam", + } + monkeypatch.setattr(ios_wda, "_provisioning_profiles", + lambda: iter([(tmp_path / "x.mobileprovision", profile)])) + state, details = ios_wda.check_wda_signing(wda_bundle="com.test.wda") + assert state == "expired" + assert "ago" in details + + +def test_check_signing_picks_latest_expiry(monkeypatch, tmp_path): + monkeypatch.setattr(ios_wda.sys, "platform", "darwin") + monkeypatch.setattr(ios_wda.shutil, "which", lambda _: "/usr/bin/security") + older = datetime.now(timezone.utc) + timedelta(days=5) + newer = datetime.now(timezone.utc) + timedelta(days=60) + p1 = {"Entitlements": {"application-identifier": "ABC.com.test.wda"}, + "ExpirationDate": older, "TeamName": "Old"} + p2 = {"Entitlements": {"application-identifier": "ABC.com.test.wda"}, + "ExpirationDate": newer, "TeamName": "New"} + monkeypatch.setattr(ios_wda, "_provisioning_profiles", + lambda: iter([(tmp_path / "old.mp", p1), + (tmp_path / "new.mp", p2)])) + state, details = ios_wda.check_wda_signing(wda_bundle="com.test.wda") + assert state == "signed" + assert "New" in details + + +def test_main_check_only_returns_1_when_not_signed(monkeypatch, capsys): + monkeypatch.setattr(ios_wda, "check_wda_signing", lambda: ("not_signed", "test")) + rc = ios_wda.main(["--check"]) + assert rc == 1 + out = capsys.readouterr().out + assert "signed" in out # output still mentions "signed" (in "not_signed") + + +def test_main_check_only_returns_0_when_signed(monkeypatch, capsys): + monkeypatch.setattr(ios_wda, "check_wda_signing", lambda: ("signed", "valid")) + rc = ios_wda.main(["--check"]) + assert rc == 0 + + +def test_main_help_returns_0_and_prints_usage(capsys): + rc = ios_wda.main(["--help"]) + assert rc == 0 + out = capsys.readouterr().out + assert "sign-wda" in out.lower() + assert "--check" in out + + +def test_main_no_check_when_signed(monkeypatch, capsys): + monkeypatch.setattr(ios_wda, "check_wda_signing", lambda: ("signed", "TestTeam")) + rc = ios_wda.main([]) + assert rc == 0 + out = capsys.readouterr().out + assert "signed" in out + + +def test_main_when_not_signed_no_project_returns_1(monkeypatch, capsys): + monkeypatch.setattr(ios_wda, "check_wda_signing", lambda: ("not_signed", "test")) + monkeypatch.setattr(ios_wda, "find_wda_project", lambda: None) + rc = ios_wda.main([]) + assert rc == 1 + out = capsys.readouterr().out + assert "appium driver install xcuitest" in out + + +def test_main_output_matches_doctor_pattern(monkeypatch, capsys): + """The output must contain a recognizable state for the doctor + grep verify.""" + monkeypatch.setattr(ios_wda, "check_wda_signing", lambda: ("expired", "30d ago")) + ios_wda.main(["--check"]) + out = capsys.readouterr().out + assert any(s in out for s in ("signed", "expired", "not signed")) + + +def test_matches_wda_app_id_suffix(): + profile = {"Entitlements": {"application-identifier": "ABC123.com.user.wda"}} + assert ios_wda._matches_wda(profile, "com.user.wda") is True + profile2 = {"Entitlements": {"application-identifier": "ABC123.com.other.app"}} + assert ios_wda._matches_wda(profile2, "com.user.wda") is False + + +def test_matches_wda_handles_empty_entitlements(): + assert ios_wda._matches_wda({}, "com.user.wda") is False + assert ios_wda._matches_wda({"Entitlements": None}, "com.user.wda") is False + + +def test_check_wda_built_returns_false_when_no_derived_data(tmp_path, monkeypatch): + fake_home = tmp_path + monkeypatch.setenv("HOME", str(fake_home)) + built, detail = ios_wda.check_wda_built() + assert built is False + assert "DerivedData" in detail or "not on macOS" in detail + + +def test_check_wda_built_returns_false_when_no_app(tmp_path, monkeypatch): + monkeypatch.setattr(ios_wda.sys, "platform", "darwin") + derived = tmp_path / "Library/Developer/Xcode/DerivedData" + derived.mkdir(parents=True) + monkeypatch.setenv("HOME", str(tmp_path)) + built, detail = ios_wda.check_wda_built() + assert built is False + assert "no WebDriverAgentRunner-Runner.app" in detail + + +def test_check_wda_built_returns_true_when_app_present(tmp_path, monkeypatch): + monkeypatch.setattr(ios_wda.sys, "platform", "darwin") + monkeypatch.setenv("HOME", str(tmp_path)) + app = tmp_path / "Library/Developer/Xcode/DerivedData/WebDriverAgent-abc/Build/Products/Debug-iphoneos/WebDriverAgentRunner-Runner.app" + app.mkdir(parents=True) + built, detail = ios_wda.check_wda_built() + assert built is True + assert "built at" in detail + + +def test_build_main_help_returns_0(capsys): + rc = ios_wda.build_main(["--help"]) + assert rc == 0 + out = capsys.readouterr().out + assert "build-wda" in out + assert "--check" in out + + +def test_build_main_check_returns_1_when_not_built(monkeypatch, capsys): + monkeypatch.setattr(ios_wda, "check_wda_built", lambda: (False, "no app")) + rc = ios_wda.build_main(["--check"]) + assert rc == 1 + out = capsys.readouterr().out + assert "not built" in out + + +def test_build_main_check_returns_0_when_built(monkeypatch, capsys): + monkeypatch.setattr(ios_wda, "check_wda_built", lambda: (True, "built at WebDriverAgent-xxx")) + rc = ios_wda.build_main(["--check"]) + assert rc == 0 + out = capsys.readouterr().out + assert "WDA build: built" in out + + +def test_build_main_already_built_returns_0(monkeypatch, capsys): + monkeypatch.setattr(ios_wda, "check_wda_built", lambda: (True, "fresh")) + rc = ios_wda.build_main([]) + assert rc == 0 + out = capsys.readouterr().out + assert "already built" in out.lower() + + +def test_build_main_refuses_when_unsigned(monkeypatch, capsys): + monkeypatch.setattr(ios_wda, "check_wda_built", lambda: (False, "missing")) + monkeypatch.setattr(ios_wda, "check_wda_signing", lambda: ("not_signed", "no profile")) + rc = ios_wda.build_main([]) + assert rc == 1 + out = capsys.readouterr().out + assert "sign-wda" in out + + +def test_build_wda_returns_error_when_project_missing(monkeypatch): + monkeypatch.setattr(ios_wda, "find_wda_project", lambda: None) + rc, output = ios_wda.build_wda() + assert rc == 1 + assert "appium driver install xcuitest" in output + + +def test_team_id_arg_empty_when_unset(monkeypatch): + monkeypatch.delenv("IPH_XCODE_ORG_ID", raising=False) + assert ios_wda._team_id_arg() == [] + + +def test_team_id_arg_propagates(monkeypatch): + monkeypatch.setenv("IPH_XCODE_ORG_ID", "ABCD123456") + assert ios_wda._team_id_arg() == ["DEVELOPMENT_TEAM=ABCD123456"] + + +def test_bundle_id_arg_propagates(monkeypatch): + monkeypatch.setenv("IPH_WDA_BUNDLE_ID", "com.example.wda") + assert ios_wda._bundle_id_arg() == ["PRODUCT_BUNDLE_IDENTIFIER=com.example.wda"] diff --git a/tests/test_ipc.py b/tests/test_ipc.py new file mode 100644 index 0000000..a1dd64b --- /dev/null +++ b/tests/test_ipc.py @@ -0,0 +1,356 @@ +"""IPC layer tests — exercise the AF_UNIX JSON-line protocol without a real daemon. + +Spawns the mock daemon subprocesses (tests/_mock_iphone_daemon.py and +tests/_mock_android_daemon.py) which mirror the real daemon's IPC contract +minus Appium/device dependencies. Each test gets a unique IPH_NAME/ANH_NAME so +socket files at /tmp/iph-.sock and /tmp/anh-.sock don't collide. +""" +import json +import os +import socket +import subprocess +import sys +import time +import uuid +from pathlib import Path + +import pytest + +from iphone_harness import _ipc as iph_ipc +from android_harness import _ipc as anh_ipc + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _wait_alive(ipc_mod, name, timeout=5.0): + deadline = time.time() + timeout + while time.time() < deadline: + if ipc_mod.ping(name, timeout=0.3): + return True + time.sleep(0.05) + return False + + +def _spawn_mock(platform, name): + """Spawn mock iphone/android daemon. Returns Popen handle.""" + if platform == "iphone": + module = "tests._mock_iphone_daemon" + env_var = "IPH_NAME" + else: + module = "tests._mock_android_daemon" + env_var = "ANH_NAME" + env = {**os.environ, env_var: name} + return subprocess.Popen( + [sys.executable, "-m", module], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + cwd=str(REPO_ROOT), + start_new_session=True, + ) + + +def _cleanup_files(platform, name): + prefix = "iph" if platform == "iphone" else "anh" + for ext in ("sock", "pid", "log"): + try: + (Path("/tmp") / f"{prefix}-{name}.{ext}").unlink() + except FileNotFoundError: + pass + + +@pytest.fixture +def iph_name(): + n = f"tst{uuid.uuid4().hex[:10]}" + os.environ["IPH_NAME"] = n + yield n + _cleanup_files("iphone", n) + os.environ.pop("IPH_NAME", None) + + +@pytest.fixture +def anh_name(): + n = f"tst{uuid.uuid4().hex[:10]}" + os.environ["ANH_NAME"] = n + yield n + _cleanup_files("android", n) + os.environ.pop("ANH_NAME", None) + + +@pytest.fixture +def iph_daemon(iph_name): + p = _spawn_mock("iphone", iph_name) + if not _wait_alive(iph_ipc, iph_name, timeout=5.0): + p.kill() + p.wait(timeout=2.0) + pytest.fail("mock iphone daemon never came up") + yield iph_name, p + try: + s, _ = iph_ipc.connect(iph_name, timeout=1.0) + iph_ipc.request(s, None, {"meta": "shutdown"}) + s.close() + except Exception: + pass + try: + p.wait(timeout=3.0) + except subprocess.TimeoutExpired: + p.kill() + p.wait(timeout=2.0) + + +@pytest.fixture +def anh_daemon(anh_name): + p = _spawn_mock("android", anh_name) + if not _wait_alive(anh_ipc, anh_name, timeout=5.0): + p.kill() + p.wait(timeout=2.0) + pytest.fail("mock android daemon never came up") + yield anh_name, p + try: + s, _ = anh_ipc.connect(anh_name, timeout=1.0) + anh_ipc.request(s, None, {"meta": "shutdown"}) + s.close() + except Exception: + pass + try: + p.wait(timeout=3.0) + except subprocess.TimeoutExpired: + p.kill() + p.wait(timeout=2.0) + + +# ---- iphone -------------------------------------------------------------- + +def test_ping_returns_false_when_no_daemon(iph_name): + assert iph_ipc.ping(iph_name, timeout=0.3) is False + + +def test_identify_returns_none_when_no_daemon(iph_name): + assert iph_ipc.identify(iph_name, timeout=0.3) is None + + +def test_ping_returns_true_when_daemon_alive(iph_daemon): + name, _ = iph_daemon + assert iph_ipc.ping(name, timeout=1.0) is True + + +def test_identify_returns_pid_when_daemon_alive(iph_daemon): + name, p = iph_daemon + pid = iph_ipc.identify(name, timeout=1.0) + assert pid == p.pid + + +def test_request_roundtrip_basic(iph_daemon): + name, _ = iph_daemon + s, token = iph_ipc.connect(name, timeout=1.0) + try: + resp = iph_ipc.request(s, token, {"method": "window_size", "params": {}}) + assert "result" in resp + assert resp["result"]["width"] == 390 + finally: + s.close() + + +def test_request_appium_passthrough(iph_daemon): + name, _ = iph_daemon + s, token = iph_ipc.connect(name, timeout=1.0) + try: + resp = iph_ipc.request(s, token, { + "method": "appium", + "params": {"script": "mobile: activeAppInfo", "args": {}}, + }) + assert resp["result"]["bundleId"] == "com.apple.springboard" + finally: + s.close() + + +def test_request_unknown_method_returns_error(iph_daemon): + name, _ = iph_daemon + s, token = iph_ipc.connect(name, timeout=1.0) + try: + resp = iph_ipc.request(s, token, {"method": "no_such_method", "params": {}}) + assert "error" in resp + assert "unknown method" in resp["error"] + finally: + s.close() + + +def test_request_missing_method_returns_error(iph_daemon): + name, _ = iph_daemon + s, token = iph_ipc.connect(name, timeout=1.0) + try: + resp = iph_ipc.request(s, token, {"params": {}}) + assert "error" in resp + assert "missing method" in resp["error"] + finally: + s.close() + + +def test_request_server_side_raise_returns_error(iph_daemon): + name, _ = iph_daemon + s, token = iph_ipc.connect(name, timeout=1.0) + try: + resp = iph_ipc.request(s, token, {"method": "raise", "params": {}}) + assert "error" in resp + assert "intentional crash" in resp["error"] + finally: + s.close() + + +def test_malformed_json_request_does_not_crash_daemon(iph_daemon): + name, _ = iph_daemon + s, _ = iph_ipc.connect(name, timeout=1.0) + try: + s.sendall(b"this is not json\n") + data = b"" + while not data.endswith(b"\n"): + chunk = s.recv(1 << 16) + if not chunk: + break + data += chunk + resp = json.loads(data) + assert "error" in resp + assert "bad json" in resp["error"] + finally: + s.close() + # Daemon still alive after. + assert iph_ipc.ping(name, timeout=1.0) is True + + +def test_shutdown_via_ipc_stops_daemon(iph_name): + p = _spawn_mock("iphone", iph_name) + try: + assert _wait_alive(iph_ipc, iph_name, timeout=5.0) + s, _ = iph_ipc.connect(iph_name, timeout=1.0) + resp = iph_ipc.request(s, None, {"meta": "shutdown"}) + s.close() + assert resp == {"ok": True} + # Daemon should exit within a few seconds. + try: + p.wait(timeout=5.0) + except subprocess.TimeoutExpired: + p.kill() + pytest.fail("daemon didn't exit after shutdown") + assert iph_ipc.ping(iph_name, timeout=0.5) is False + finally: + if p.poll() is None: + p.kill() + p.wait(timeout=2.0) + + +def test_invalid_name_rejected_iphone(): + with pytest.raises(ValueError): + iph_ipc.sock_addr("../etc/passwd") + with pytest.raises(ValueError): + iph_ipc.sock_addr("name with spaces") + with pytest.raises(ValueError): + iph_ipc.sock_addr("") + + +def test_socket_file_removed_after_shutdown(iph_name): + p = _spawn_mock("iphone", iph_name) + try: + assert _wait_alive(iph_ipc, iph_name, timeout=5.0) + sock_path = Path(iph_ipc.sock_addr(iph_name)) + assert sock_path.exists() + s, _ = iph_ipc.connect(iph_name, timeout=1.0) + iph_ipc.request(s, None, {"meta": "shutdown"}) + s.close() + p.wait(timeout=5.0) + # Mock daemon's serve() calls cleanup_endpoint in its finally. + assert not sock_path.exists() + finally: + if p.poll() is None: + p.kill() + p.wait(timeout=2.0) + + +def test_connect_fails_fast_when_no_daemon(iph_name): + """connect() should raise quickly, not hang.""" + t0 = time.time() + with pytest.raises((FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError)): + iph_ipc.connect(iph_name, timeout=0.5) + elapsed = time.time() - t0 + assert elapsed < 2.0 + + +# ---- android -------------------------------------------------------------- + +def test_android_ping_no_daemon(anh_name): + assert anh_ipc.ping(anh_name, timeout=0.3) is False + + +def test_android_daemon_alive(anh_daemon): + name, _ = anh_daemon + assert anh_ipc.ping(name, timeout=1.0) is True + + +def test_android_identify_returns_pid(anh_daemon): + name, p = anh_daemon + pid = anh_ipc.identify(name, timeout=1.0) + assert pid == p.pid + + +def test_android_request_roundtrip(anh_daemon): + name, _ = anh_daemon + s, token = anh_ipc.connect(name, timeout=1.0) + try: + resp = anh_ipc.request(s, token, {"method": "window_size", "params": {}}) + assert resp["result"]["width"] == 1080 + finally: + s.close() + + +def test_android_invalid_name_rejected(): + with pytest.raises(ValueError): + anh_ipc.sock_addr("../etc/passwd") + with pytest.raises(ValueError): + anh_ipc.sock_addr("") + + +def test_iph_ipc_caps_oversized_response(iph_daemon, monkeypatch): + """IPC client should reject responses larger than _MAX_MSG (defense in depth).""" + name, _ = iph_daemon + s, token = iph_ipc.connect(name, timeout=1.0) + try: + # Temporarily lower the cap so we can trigger it without 64MB of data + monkeypatch.setattr(iph_ipc, "_MAX_MSG", 100) + # Ask the mock daemon for something whose JSON-encoded result exceeds 100 bytes. + # `page_source` returns an XML string from the mock — small but request adds overhead. + # Send a request that would yield a >100-byte JSON response. + with pytest.raises(RuntimeError, match="exceeded.*MB cap"): + iph_ipc.request(s, token, { + "method": "appium", + "params": {"script": "x" * 200, "args": {}}, + }) + finally: + try: + s.close() + except Exception: + pass + + +def test_anh_ipc_caps_oversized_response(anh_daemon, monkeypatch): + name, _ = anh_daemon + s, token = anh_ipc.connect(name, timeout=1.0) + try: + # Drop the cap below any plausible response — even a 60-byte appium reply trips it. + monkeypatch.setattr(anh_ipc, "_MAX_MSG", 10) + with pytest.raises(RuntimeError, match="exceeded.*MB cap"): + anh_ipc.request(s, token, {"method": "appium", "params": {}}) + finally: + try: + s.close() + except Exception: + pass + + +def test_android_iphone_namespaces_separate(iph_name, anh_name): + """iOS and Android socket namespaces must not collide for the same name.""" + # Same name but different prefixes — should produce different paths. + assert iph_ipc.sock_addr(iph_name) != anh_ipc.sock_addr(anh_name) + # Even with the SAME name argument, prefixes differ: + same_name = "shared" + assert iph_ipc.sock_addr(same_name).endswith(f"iph-{same_name}.sock") + assert anh_ipc.sock_addr(same_name).endswith(f"anh-{same_name}.sock") diff --git a/tests/test_ipc_tcp.py b/tests/test_ipc_tcp.py new file mode 100644 index 0000000..4616aba --- /dev/null +++ b/tests/test_ipc_tcp.py @@ -0,0 +1,301 @@ +"""TCP transport tests for iphone_harness + android_harness IPC. + +Same mock daemons as test_ipc.py — the transport switch is env-driven +(IPH_BIND/IPH_CONNECT for iOS, ANH_BIND/ANH_CONNECT for Android), so the +daemon code path is identical between unix and tcp. These tests ensure +the swap is transparent at the protocol layer. +""" +import contextlib +import os +import socket +import subprocess +import sys +import time +import uuid +from pathlib import Path + +import pytest + +from iphone_harness import _ipc as iph_ipc +from android_harness import _ipc as anh_ipc + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _free_port(): + """Bind a system-assigned port, close, return it. Race-y but fine for tests.""" + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + finally: + s.close() + + +def _wait_alive(ipc_mod, name, timeout=5.0): + deadline = time.time() + timeout + while time.time() < deadline: + if ipc_mod.ping(name, timeout=0.3): + return True + time.sleep(0.05) + return False + + +def _spawn_mock_tcp(platform, name, port): + """Spawn a mock daemon listening on tcp://127.0.0.1:.""" + if platform == "iphone": + module = "tests._mock_iphone_daemon" + env = {**os.environ, "IPH_NAME": name, "IPH_BIND": f"tcp://127.0.0.1:{port}"} + else: + module = "tests._mock_android_daemon" + env = {**os.environ, "ANH_NAME": name, "ANH_BIND": f"tcp://127.0.0.1:{port}"} + return subprocess.Popen( + [sys.executable, "-m", module], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + cwd=str(REPO_ROOT), + start_new_session=True, + ) + + +@contextlib.contextmanager +def _tcp_env(prefix, port): + """Scope IPH_CONNECT/ANH_CONNECT to this block.""" + saved = {k: os.environ.get(k) for k in (f"{prefix}_CONNECT", f"{prefix}_BIND")} + os.environ[f"{prefix}_CONNECT"] = f"tcp://127.0.0.1:{port}" + try: + yield + finally: + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def _cleanup_files(platform, name): + prefix = "iph" if platform == "iphone" else "anh" + for ext in ("sock", "pid", "log"): + try: + (Path("/tmp") / f"{prefix}-{name}.{ext}").unlink() + except FileNotFoundError: + pass + + +# ---- parse_endpoint ------------------------------------------------------- + +@pytest.mark.parametrize("mod", [iph_ipc, anh_ipc]) +def test_transport_parse_unix(mod): + assert mod.parse_endpoint("unix:/tmp/foo.sock") == ("unix", "/tmp/foo.sock") + assert mod.parse_endpoint("unix:///tmp/foo.sock") == ("unix", "/tmp/foo.sock") + + +@pytest.mark.parametrize("mod", [iph_ipc, anh_ipc]) +def test_transport_parse_tcp(mod): + assert mod.parse_endpoint("tcp://127.0.0.1:7300") == ("tcp", "127.0.0.1", 7300) + assert mod.parse_endpoint("tcp://localhost:8000") == ("tcp", "localhost", 8000) + assert mod.parse_endpoint("tcp://[::1]:9000") == ("tcp", "::1", 9000) + + +@pytest.mark.parametrize("mod", [iph_ipc, anh_ipc]) +def test_transport_parse_rejects_malformed(mod): + for bad in ("", "http://x", "tcp://noport", "tcp://h:abc", "tcp://h:0", + "tcp://h:99999", "unix:"): + with pytest.raises(ValueError): + mod.parse_endpoint(bad) + + +# ---- sock_addr URI form --------------------------------------------------- + +def test_transport_sock_addr_tcp_returns_uri_iphone(monkeypatch): + monkeypatch.setenv("IPH_BIND", "tcp://127.0.0.1:9876") + assert iph_ipc.sock_addr("test") == "tcp://127.0.0.1:9876" + + +def test_transport_sock_addr_tcp_returns_uri_android(monkeypatch): + monkeypatch.setenv("ANH_BIND", "tcp://127.0.0.1:9877") + assert anh_ipc.sock_addr("test") == "tcp://127.0.0.1:9877" + + +def test_transport_sock_addr_unix_unchanged_default(monkeypatch): + """No IPH_BIND set → behaves exactly like the old unix-only sock_addr.""" + monkeypatch.delenv("IPH_BIND", raising=False) + addr = iph_ipc.sock_addr("default") + assert addr.endswith(".sock") + assert "tcp://" not in addr + + +# ---- cleanup_endpoint dispatch ------------------------------------------- + +def test_transport_cleanup_endpoint_noop_for_tcp(monkeypatch, tmp_path): + """In TCP mode the unix file path doesn't exist; cleanup must not raise.""" + monkeypatch.setenv("IPH_BIND", "tcp://127.0.0.1:7301") + iph_ipc.cleanup_endpoint("noexist") + iph_ipc.cleanup_endpoint("noexist") + + +# ---- bind_endpoint / connect_endpoint resolution ------------------------- + +def test_transport_default_is_unix_iphone(monkeypatch): + for k in ("IPH_BIND", "IPH_CONNECT"): + monkeypatch.delenv(k, raising=False) + bep = iph_ipc.bind_endpoint("default") + cep = iph_ipc.connect_endpoint("default") + assert bep[0] == "unix" and cep[0] == "unix" + assert bep[1].endswith(".sock") + + +def test_transport_env_override_to_tcp(monkeypatch): + monkeypatch.setenv("IPH_BIND", "tcp://127.0.0.1:9999") + monkeypatch.setenv("IPH_CONNECT", "tcp://127.0.0.1:9999") + assert iph_ipc.bind_endpoint("any") == ("tcp", "127.0.0.1", 9999) + assert iph_ipc.connect_endpoint("any") == ("tcp", "127.0.0.1", 9999) + + +# ---- live TCP daemon — iphone -------------------------------------------- + +def test_ipc_tcp_iphone_roundtrip(): + """Mock daemon binds tcp, client connects tcp, ping + method dispatch.""" + name = f"tst{uuid.uuid4().hex[:10]}" + port = _free_port() + p = _spawn_mock_tcp("iphone", name, port) + try: + with _tcp_env("IPH", port): + os.environ["IPH_NAME"] = name + try: + assert _wait_alive(iph_ipc, name, timeout=5.0), "daemon never bound TCP" + assert iph_ipc.ping(name, timeout=1.0) is True + pid = iph_ipc.identify(name, timeout=1.0) + assert pid == p.pid + s, _ = iph_ipc.connect(name, timeout=1.0) + try: + resp = iph_ipc.request(s, None, {"method": "window_size", "params": {}}) + assert resp["result"]["width"] == 390 + finally: + s.close() + finally: + os.environ.pop("IPH_NAME", None) + finally: + if p.poll() is None: + try: + p.terminate() + p.wait(timeout=2.0) + except subprocess.TimeoutExpired: + p.kill() + p.wait(timeout=2.0) + _cleanup_files("iphone", name) + + +def test_ipc_tcp_iphone_no_unix_file_created(): + """TCP-mode daemon must NOT create /tmp/iph-.sock on disk.""" + name = f"tst{uuid.uuid4().hex[:10]}" + port = _free_port() + sock_file = Path("/tmp") / f"iph-{name}.sock" + try: sock_file.unlink() + except FileNotFoundError: pass + + p = _spawn_mock_tcp("iphone", name, port) + try: + with _tcp_env("IPH", port): + os.environ["IPH_NAME"] = name + try: + assert _wait_alive(iph_ipc, name, timeout=5.0) + assert not sock_file.exists(), f"TCP daemon unexpectedly created {sock_file}" + finally: + os.environ.pop("IPH_NAME", None) + finally: + if p.poll() is None: + try: + p.terminate() + p.wait(timeout=2.0) + except subprocess.TimeoutExpired: + p.kill() + p.wait(timeout=2.0) + _cleanup_files("iphone", name) + + +def test_ipc_tcp_iphone_shutdown_via_ipc(): + name = f"tst{uuid.uuid4().hex[:10]}" + port = _free_port() + p = _spawn_mock_tcp("iphone", name, port) + try: + with _tcp_env("IPH", port): + os.environ["IPH_NAME"] = name + try: + assert _wait_alive(iph_ipc, name, timeout=5.0) + s, _ = iph_ipc.connect(name, timeout=1.0) + resp = iph_ipc.request(s, None, {"meta": "shutdown"}) + s.close() + assert resp == {"ok": True} + try: + p.wait(timeout=5.0) + except subprocess.TimeoutExpired: + pytest.fail("TCP daemon didn't exit after shutdown") + assert iph_ipc.ping(name, timeout=0.5) is False + finally: + os.environ.pop("IPH_NAME", None) + finally: + if p.poll() is None: + p.kill() + p.wait(timeout=2.0) + _cleanup_files("iphone", name) + + +# ---- live TCP daemon — android ------------------------------------------- + +def test_ipc_tcp_android_roundtrip(): + name = f"tst{uuid.uuid4().hex[:10]}" + port = _free_port() + p = _spawn_mock_tcp("android", name, port) + try: + with _tcp_env("ANH", port): + os.environ["ANH_NAME"] = name + try: + assert _wait_alive(anh_ipc, name, timeout=5.0) + s, _ = anh_ipc.connect(name, timeout=1.0) + try: + resp = anh_ipc.request(s, None, {"method": "window_size", "params": {}}) + assert resp["result"]["width"] == 1080 + finally: + s.close() + finally: + os.environ.pop("ANH_NAME", None) + finally: + if p.poll() is None: + try: + p.terminate() + p.wait(timeout=2.0) + except subprocess.TimeoutExpired: + p.kill() + p.wait(timeout=2.0) + _cleanup_files("android", name) + + +# ---- server bind warning when non-loopback ------------------------------- + +def test_transport_serve_warns_on_non_loopback(monkeypatch, capfd): + """Binding to 0.0.0.0 (non-loopback) must emit a security warning.""" + import asyncio + + port = _free_port() + monkeypatch.setenv("IPH_BIND", f"tcp://0.0.0.0:{port}") + + async def _spin(): + async def _handler(reader, writer): + writer.close() + task = asyncio.create_task(iph_ipc.serve("warntest", _handler)) + await asyncio.sleep(0.1) + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + + try: + asyncio.run(_spin()) + except Exception: + pass + + out, err = capfd.readouterr() + combined = out + err + assert "non-loopback" in combined or "WARNING" in combined.upper() diff --git a/tests/test_nav_parity.py b/tests/test_nav_parity.py new file mode 100644 index 0000000..08bde58 --- /dev/null +++ b/tests/test_nav_parity.py @@ -0,0 +1,94 @@ +"""iOS navigation parity tests — press_home, swipe_back, press_back, press_recents. + +iOS gets first-class equivalents to Android's hardware-button helpers. +press_home uses XCUITest pressButton; swipe_back/press_recents use gestures. +""" + + +def test_ios_press_home_calls_press_button(monkeypatch): + import iphone_harness.helpers as iph + + captured = [] + monkeypatch.setattr(iph, "appium", lambda script, **kw: captured.append((script, kw))) + + iph.press_home() + + assert captured == [("mobile: pressButton", {"name": "home"})] + + +def test_ios_swipe_back_uses_left_edge_gesture(monkeypatch): + import iphone_harness.helpers as iph + + monkeypatch.setattr(iph, "window_size", lambda: {"width": 390, "height": 844}) + captured = [] + monkeypatch.setattr(iph, "appium", lambda script, **kw: captured.append((script, kw))) + + iph.swipe_back() + + assert len(captured) == 1 + script, kw = captured[0] + assert script == "mobile: dragFromToForDuration" + assert kw["fromX"] <= 5 + assert kw["toX"] >= kw["fromX"] + 100 + assert kw["fromY"] == kw["toY"] + + +def test_ios_press_back_aliases_swipe_back(monkeypatch): + import iphone_harness.helpers as iph + + monkeypatch.setattr(iph, "window_size", lambda: {"width": 390, "height": 844}) + captured = [] + monkeypatch.setattr(iph, "appium", lambda script, **kw: captured.append((script, kw))) + + iph.press_back() + + assert len(captured) == 1 + assert captured[0][0] == "mobile: dragFromToForDuration" + + +def test_ios_press_recents_opens_app_switcher(monkeypatch): + import iphone_harness.helpers as iph + + monkeypatch.setattr(iph, "window_size", lambda: {"width": 390, "height": 844}) + monkeypatch.setattr(iph, "wait", lambda *a, **kw: None) + captured = [] + monkeypatch.setattr(iph, "appium", lambda script, **kw: captured.append((script, kw))) + + iph.press_recents() + + assert len(captured) == 1 + script, kw = captured[0] + assert script == "mobile: dragFromToForDuration" + assert kw["fromY"] > kw["toY"] + + +def test_ios_open_app_switcher_is_same_as_press_recents(monkeypatch): + import iphone_harness.helpers as iph + + monkeypatch.setattr(iph, "window_size", lambda: {"width": 390, "height": 844}) + monkeypatch.setattr(iph, "wait", lambda *a, **kw: None) + captured = [] + monkeypatch.setattr(iph, "appium", lambda script, **kw: captured.append((script, kw))) + + iph.open_app_switcher() + + assert len(captured) == 1 + assert captured[0][0] == "mobile: dragFromToForDuration" + + +def test_android_button_helpers_unchanged(): + """Sanity: Android helpers we mirror still exist.""" + from android_harness.helpers import press_back, press_home, press_recents + assert callable(press_back) + assert callable(press_home) + assert callable(press_recents) + + +def test_ios_helpers_match_android_api_surface(): + """Symmetry check: every Android button helper has an iOS equivalent.""" + import android_harness.helpers as anh + import iphone_harness.helpers as iph + + for name in ("press_home", "press_back", "press_recents"): + assert hasattr(anh, name), f"missing on android: {name}" + assert hasattr(iph, name), f"missing on ios: {name}" diff --git a/tests/test_ocr_platform.py b/tests/test_ocr_platform.py new file mode 100644 index 0000000..16e2a84 --- /dev/null +++ b/tests/test_ocr_platform.py @@ -0,0 +1,78 @@ +"""OCR must fail cleanly on Linux (no ImportError crash; clear remediation). + +Both `iphone_harness.helpers.ocr` and `android_harness.helpers.ocr` use the +macOS Vision framework. On Linux they must raise `OCRNotAvailableError` +with a useful message pointing at SETUP.md, not a bare ImportError. + +`screenshot()` is mocked so we don't need a live daemon or device — we +test the platform gate in isolation. +""" +import sys + +import pytest + +from mobile_use._platform import OCRNotAvailableError + + +def _mock_screenshot(monkeypatch, helpers_mod): + """Stub the helpers' screenshot() so ocr() can run without a device.""" + monkeypatch.setattr(helpers_mod, "screenshot", lambda *a, **kw: "/tmp/fake.png") + + +def test_ocr_not_available_error_is_runtime_error_subclass(): + assert issubclass(OCRNotAvailableError, RuntimeError) + + +def test_iphone_ocr_raises_on_linux(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + from iphone_harness import helpers + _mock_screenshot(monkeypatch, helpers) + with pytest.raises(OCRNotAvailableError) as exc_info: + helpers.ocr() + msg = str(exc_info.value) + assert "macOS" in msg or "Vision" in msg + assert "tesseract" in msg.lower() or "SETUP.md" in msg + + +def test_android_ocr_raises_on_linux(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + from android_harness import helpers + _mock_screenshot(monkeypatch, helpers) + with pytest.raises(OCRNotAvailableError) as exc_info: + helpers.ocr() + msg = str(exc_info.value) + assert "macOS" in msg or "Vision" in msg + assert "tesseract" in msg.lower() or "SETUP.md" in msg + + +def test_iphone_ocr_does_not_crash_at_import_on_linux(monkeypatch): + """Importing the helpers module on Linux must not blow up. + + The Vision/Foundation imports are lazy (inside the ocr() function body), + so a Linux host with no pyobjc can still import the module — and access + every non-OCR helper without trouble. + """ + monkeypatch.setattr(sys, "platform", "linux") + # Force a fresh import path + for mod in ("iphone_harness.helpers", "android_harness.helpers"): + sys.modules.pop(mod, None) + import iphone_harness.helpers # noqa: F401 + import android_harness.helpers # noqa: F401 + + +def test_iphone_ocr_error_is_caught_as_runtime_error(monkeypatch): + """Back-compat: legacy callers that catch RuntimeError still work.""" + monkeypatch.setattr(sys, "platform", "linux") + from iphone_harness import helpers + _mock_screenshot(monkeypatch, helpers) + with pytest.raises(RuntimeError): + helpers.ocr() + + +def test_android_ocr_message_mentions_setup_doc(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + from android_harness import helpers + _mock_screenshot(monkeypatch, helpers) + with pytest.raises(OCRNotAvailableError) as exc_info: + helpers.ocr() + assert "SETUP.md" in str(exc_info.value) diff --git a/tests/test_platform.py b/tests/test_platform.py new file mode 100644 index 0000000..14e23a7 --- /dev/null +++ b/tests/test_platform.py @@ -0,0 +1,210 @@ +"""Unit tests for mobile_use._platform. + +Validates platform detection, Linux pkg manager detection across all five +supported managers (apt/dnf/pacman/zypper/apk), sudo prefix logic, and the +unified `linux_install_cmd` builder. +""" +import sys +from pathlib import Path + +import pytest + +from mobile_use import _platform + + +def _stub_os_release(monkeypatch, contents: str): + """Make Path('/etc/os-release').read_text() return `contents`.""" + real_read = Path.read_text + + def fake_read(self, *a, **kw): + if str(self) == "/etc/os-release": + return contents + return real_read(self, *a, **kw) + monkeypatch.setattr(Path, "read_text", fake_read) + + +def test_is_linux_is_macos_mutually_exclusive(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + assert _platform.is_linux() + assert not _platform.is_macos() + + monkeypatch.setattr(sys, "platform", "darwin") + assert not _platform.is_linux() + assert _platform.is_macos() + + +def test_linux_pkg_manager_returns_none_on_macos(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + assert _platform.linux_pkg_manager() is None + + +def test_linux_pkg_manager_detects_ubuntu(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + _stub_os_release(monkeypatch, 'ID=ubuntu\nID_LIKE=debian\n') + monkeypatch.setattr(_platform.shutil, "which", lambda c: None) + assert _platform.linux_pkg_manager() == "apt" + + +def test_linux_pkg_manager_detects_fedora(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + _stub_os_release(monkeypatch, 'ID=fedora\n') + monkeypatch.setattr(_platform.shutil, "which", lambda c: None) + assert _platform.linux_pkg_manager() == "dnf" + + +def test_linux_pkg_manager_detects_arch(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + _stub_os_release(monkeypatch, 'ID=arch\n') + monkeypatch.setattr(_platform.shutil, "which", lambda c: None) + assert _platform.linux_pkg_manager() == "pacman" + + +def test_linux_pkg_manager_detects_opensuse(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + _stub_os_release(monkeypatch, 'ID=opensuse-leap\nID_LIKE="suse opensuse"\n') + monkeypatch.setattr(_platform.shutil, "which", lambda c: None) + assert _platform.linux_pkg_manager() == "zypper" + + +def test_linux_pkg_manager_detects_alpine(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + _stub_os_release(monkeypatch, 'ID=alpine\n') + monkeypatch.setattr(_platform.shutil, "which", lambda c: None) + assert _platform.linux_pkg_manager() == "apk" + + +def test_linux_pkg_manager_falls_back_to_path(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + _stub_os_release(monkeypatch, '') # empty os-release + monkeypatch.setattr(_platform.shutil, "which", + lambda c: "/usr/bin/dnf" if c == "dnf" else None) + assert _platform.linux_pkg_manager() == "dnf" + + +def test_linux_pkg_manager_unknown_distro_returns_none(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + _stub_os_release(monkeypatch, 'ID=nixos\n') + monkeypatch.setattr(_platform.shutil, "which", lambda c: None) + assert _platform.linux_pkg_manager() is None + + +def test_sudo_prefix_macos_returns_empty(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + assert _platform.sudo_prefix() == [] + + +def test_sudo_prefix_linux_root_returns_empty(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(_platform.os, "geteuid", lambda: 0) + assert _platform.sudo_prefix() == [] + + +def test_sudo_prefix_linux_nonroot_with_sudo(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(_platform.os, "geteuid", lambda: 1000) + monkeypatch.setattr(_platform.shutil, "which", + lambda c: "/usr/bin/sudo" if c == "sudo" else None) + assert _platform.sudo_prefix() == ["sudo"] + + +def test_sudo_prefix_linux_nonroot_without_sudo(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(_platform.os, "geteuid", lambda: 1000) + monkeypatch.setattr(_platform.shutil, "which", lambda c: None) + assert _platform.sudo_prefix() is None + + +@pytest.mark.parametrize("manager,expected_prefix", [ + ("apt", ["sudo", "apt", "install", "-y"]), + ("dnf", ["sudo", "dnf", "install", "-y"]), + ("pacman", ["sudo", "pacman", "-S", "--noconfirm"]), + ("zypper", ["sudo", "zypper", "install", "-y"]), + ("apk", ["sudo", "apk", "add"]), +]) +def test_linux_install_cmd_per_manager(monkeypatch, manager, expected_prefix): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: manager) + monkeypatch.setattr(_platform, "sudo_prefix", lambda: ["sudo"]) + cmd = _platform.linux_install_cmd(_platform.LINUX_ADB_PKGS) + assert cmd[:len(expected_prefix)] == expected_prefix + # last token = the package name + assert "android-tools" in cmd[-1] + + +def test_linux_install_cmd_returns_none_on_macos(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + assert _platform.linux_install_cmd(_platform.LINUX_ADB_PKGS) is None + + +def test_linux_install_cmd_returns_none_when_manager_unknown(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: None) + assert _platform.linux_install_cmd(_platform.LINUX_ADB_PKGS) is None + + +def test_linux_install_cmd_returns_none_when_sudo_missing(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: "apt") + monkeypatch.setattr(_platform, "sudo_prefix", lambda: None) + assert _platform.linux_install_cmd(_platform.LINUX_ADB_PKGS) is None + + +def test_linux_install_cmd_node_apt(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: "apt") + monkeypatch.setattr(_platform, "sudo_prefix", lambda: ["sudo"]) + cmd = _platform.linux_install_cmd(_platform.LINUX_NODE_PKGS) + assert cmd == ["sudo", "apt", "install", "-y", "nodejs", "npm"] + + +def test_linux_install_cmd_libimobiledevice_apt(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: "apt") + monkeypatch.setattr(_platform, "sudo_prefix", lambda: ["sudo"]) + cmd = _platform.linux_install_cmd(_platform.LINUX_LIBIMOBILEDEVICE_PKGS) + assert cmd[:4] == ["sudo", "apt", "install", "-y"] + assert "libimobiledevice6" in cmd + assert "usbmuxd" in cmd + + +def test_linux_install_cmd_explicit_manager_override(monkeypatch): + """Passing manager= bypasses auto-detection.""" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(_platform, "sudo_prefix", lambda: []) + cmd = _platform.linux_install_cmd(_platform.LINUX_ADB_PKGS, manager="dnf") + assert cmd == ["dnf", "install", "-y", "android-tools"] + + +def test_host_os_label_macos(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + assert _platform.host_os_label() == "macOS" + + +def test_host_os_label_linux_with_manager(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: "apt") + assert _platform.host_os_label() == "Linux (apt)" + + +def test_host_os_label_linux_unknown_manager(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(_platform, "linux_pkg_manager", lambda: None) + assert _platform.host_os_label() == "Linux (unknown pkg manager)" + + +def test_host_os_label_other_platform(monkeypatch): + monkeypatch.setattr(sys, "platform", "win32") + assert _platform.host_os_label() == "win32" + + +# Back-compat: bootstrap.py still exposes the old underscored symbols by +# delegating to _platform. Verify they still work. +def test_bootstrap_legacy_symbols_still_present(): + from mobile_use import bootstrap + # functions exist + are callable (return values depend on host, so just + # confirm shape — no exception on call) + assert callable(bootstrap._linux_pkg_manager) + assert callable(bootstrap._sudo_prefix) + assert callable(bootstrap._linux_adb_install_cmd) + assert callable(bootstrap._linux_node_install_cmd) + assert callable(bootstrap._linux_libimobiledevice_install_cmd) diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index 77b91a0..24fd58d 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -3,10 +3,12 @@ def test_imports(): - from mobile_use.quickstart import main, run_doctor_phase, run_smoke_phase # noqa + from mobile_use.quickstart import main, run_doctor_phase, run_smoke_phase, run_appium_phase, appium_reachable # noqa assert callable(main) assert callable(run_doctor_phase) assert callable(run_smoke_phase) + assert callable(run_appium_phase) + assert callable(appium_reachable) def test_main_help_smoke(capsys): @@ -32,6 +34,7 @@ def test_doctor_phase_short_circuits_on_fail(monkeypatch, capsys): from mobile_use import quickstart monkeypatch.setattr(quickstart, "_detect_platform", lambda: "ios") + monkeypatch.setattr(quickstart, "run_appium_phase", lambda **kw: (True, "stub")) called = {"smoke": 0} def fake_smoke(p): @@ -52,6 +55,7 @@ def test_skip_doctor_jumps_to_smoke(monkeypatch): from mobile_use import quickstart monkeypatch.setattr(quickstart, "_detect_platform", lambda: "android") + monkeypatch.setattr(quickstart, "run_appium_phase", lambda **kw: (True, "stub")) called = {"doctor": 0, "smoke": 0} monkeypatch.setattr(quickstart, "run_doctor_phase", @@ -63,3 +67,58 @@ def test_skip_doctor_jumps_to_smoke(monkeypatch): assert rc == 0 assert called["doctor"] == 0 assert called["smoke"] == 1 + + +def test_appium_phase_aborts_when_unreachable(monkeypatch): + """If Appium server is down and not autostart, abort before doctor.""" + from mobile_use import quickstart + + monkeypatch.setattr(quickstart, "_detect_platform", lambda: "ios") + monkeypatch.setattr(quickstart, "appium_reachable", lambda *a, **kw: False) + monkeypatch.setattr(quickstart.shutil, "which", lambda c: "/usr/local/bin/appium") + + called = {"doctor": 0} + monkeypatch.setattr(quickstart, "run_doctor_phase", + lambda p: (called.__setitem__("doctor", called["doctor"] + 1) or True, "")) + + rc = quickstart.main([]) + assert rc == 1 + assert called["doctor"] == 0, "doctor must not run when appium preflight fails" + + +def test_appium_phase_message_contains_remediation(monkeypatch, capsys): + from mobile_use import quickstart + monkeypatch.setattr(quickstart, "appium_reachable", lambda *a, **kw: False) + monkeypatch.setattr(quickstart.shutil, "which", lambda c: "/usr/local/bin/appium") + + ok, msg = quickstart.run_appium_phase(autostart=False) + assert ok is False + assert "appium --base-path /" in msg + + +def test_appium_phase_when_cli_missing(monkeypatch): + from mobile_use import quickstart + monkeypatch.setattr(quickstart, "appium_reachable", lambda *a, **kw: False) + monkeypatch.setattr(quickstart.shutil, "which", lambda c: None) + + ok, msg = quickstart.run_appium_phase(autostart=False) + assert ok is False + assert "mobile-use bootstrap" in msg + + +def test_appium_phase_passes_when_reachable(monkeypatch): + from mobile_use import quickstart + monkeypatch.setattr(quickstart, "appium_reachable", lambda *a, **kw: True) + + ok, msg = quickstart.run_appium_phase() + assert ok is True + assert "reachable" in msg + + +def test_appium_reachable_returns_false_on_url_error(monkeypatch): + from mobile_use import quickstart + import urllib.error + def boom(url, timeout=None): + raise urllib.error.URLError("connection refused") + monkeypatch.setattr(quickstart.urllib.request, "urlopen", boom) + assert quickstart.appium_reachable() is False diff --git a/tests/test_record_replay.py b/tests/test_record_replay.py new file mode 100644 index 0000000..ffc5cba --- /dev/null +++ b/tests/test_record_replay.py @@ -0,0 +1,172 @@ +"""Tests for record_replay — captures helper calls and replays them.""" +import types +from pathlib import Path + +import pytest + +from mobile_use import record_replay + + +def _make_fake_helpers(): + """Build a fake helpers module with the standard surface we record.""" + mod = types.ModuleType("fake_helpers") + calls = [] + def tap_at_xy(x, y): + calls.append(("tap_at_xy", (x, y), {})) + def swipe(x1, y1, x2, y2, duration=0.4): + calls.append(("swipe", (x1, y1, x2, y2), {"duration": duration})) + def type_text(text): + calls.append(("type_text", (text,), {})) + def scroll(direction="down"): + calls.append(("scroll", (), {"direction": direction})) + mod.tap_at_xy = tap_at_xy + mod.swipe = swipe + mod.type_text = type_text + mod.scroll = scroll + mod._calls = calls + return mod + + +def test_module_exports_api(): + assert callable(record_replay.start_recording) + assert callable(record_replay.stop_recording) + assert callable(record_replay.replay) + + +def test_recording_journals_helper_calls(tmp_path): + h = _make_fake_helpers() + out = tmp_path / "rec.py" + + record_replay.start_recording(str(out), helpers=h) + h.tap_at_xy(100, 200) + h.type_text("hello") + h.swipe(0, 500, 0, 100) + script_path = record_replay.stop_recording() + + assert script_path == str(out) + assert out.exists() + script = out.read_text() + assert "h.tap_at_xy(100, 200)" in script + assert 'h.type_text("hello")' in script + assert "h.swipe(0, 500, 0, 100" in script + + +def test_recording_forwards_to_real_helper(tmp_path): + h = _make_fake_helpers() + record_replay.start_recording(str(tmp_path / "out.py"), helpers=h) + h.tap_at_xy(1, 2) + record_replay.stop_recording() + # The wrapped call should still have been forwarded to the underlying impl. + assert h._calls == [("tap_at_xy", (1, 2), {})] + + +def test_double_start_raises(tmp_path): + h = _make_fake_helpers() + record_replay.start_recording(str(tmp_path / "a.py"), helpers=h) + try: + with pytest.raises(RuntimeError, match="already recording"): + record_replay.start_recording(str(tmp_path / "b.py"), helpers=h) + finally: + record_replay.stop_recording() + + +def test_stop_without_start_raises(): + with pytest.raises(RuntimeError, match="not recording"): + record_replay.stop_recording() + + +def test_stop_restores_helper_originals(tmp_path): + h = _make_fake_helpers() + orig = h.tap_at_xy + record_replay.start_recording(str(tmp_path / "x.py"), helpers=h) + assert h.tap_at_xy is not orig + record_replay.stop_recording() + assert h.tap_at_xy is orig + + +def test_replay_runs_recorded_script(tmp_path): + h = _make_fake_helpers() + out = tmp_path / "rec.py" + + record_replay.start_recording(str(out), helpers=h) + h.tap_at_xy(50, 60) + h.scroll(direction="up") + record_replay.stop_recording() + + # Replay against fresh helpers — should produce the same call trace. + fresh = _make_fake_helpers() + # The recorded script does `import fake_helpers as h`. Inject via helpers= arg + # which we'll have replay use *before* the script executes. But the script + # has its own `import` line — to bypass that, we use the helpers= passthrough. + # In practice, scripts will import the real helpers from the user's module. + # For our test, we monkey-patch sys.modules so `import fake_helpers` finds our fake. + import sys + sys.modules["fake_helpers"] = fresh + try: + record_replay.replay(str(out)) + assert fresh._calls == [("tap_at_xy", (50, 60), {}), ("scroll", (), {"direction": "up"})] + finally: + del sys.modules["fake_helpers"] + + +def test_replay_missing_file_raises(tmp_path): + with pytest.raises(FileNotFoundError): + record_replay.replay(str(tmp_path / "missing.py")) + + +def test_script_includes_helpers_import(tmp_path): + h = _make_fake_helpers() + h.__name__ = "fake_helpers" + out = tmp_path / "rec.py" + record_replay.start_recording(str(out), helpers=h) + h.tap_at_xy(1, 2) + record_replay.stop_recording() + script = out.read_text() + assert "import fake_helpers" in script + + +def test_quoted_strings_in_script(tmp_path): + """Strings with special chars must be safely quoted.""" + h = _make_fake_helpers() + out = tmp_path / "rec.py" + record_replay.start_recording(str(out), helpers=h) + h.type_text("hello 'world' \"quoted\"") + record_replay.stop_recording() + script = out.read_text() + # JSON-escaped — script is valid Python + assert '\\"' in script or "'" in script + compile(script, str(out), "exec") # script compiles + + +def test_is_recording_flag(tmp_path): + assert record_replay.is_recording() is False + h = _make_fake_helpers() + record_replay.start_recording(str(tmp_path / "x.py"), helpers=h) + try: + assert record_replay.is_recording() is True + finally: + record_replay.stop_recording() + assert record_replay.is_recording() is False + + +def test_records_with_iphone_harness_helpers(): + """Importing recorder against the real iphone_harness.helpers module should work.""" + import iphone_harness.helpers as iph + assert getattr(iph, "tap_at_xy", None) is not None + # Just check we can wrap and unwrap without side-effects on the daemon. + import tempfile + with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as f: + path = f.name + record_replay.start_recording(path, helpers=iph, fn_names=("tap_at_xy",)) + record_replay.stop_recording() + Path(path).unlink(missing_ok=True) + + +def test_records_with_android_harness_helpers(): + import android_harness.helpers as anh + import tempfile + with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as f: + path = f.name + record_replay.start_recording(path, helpers=anh, fn_names=("tap_at_xy",)) + record_replay.stop_recording() + Path(path).unlink(missing_ok=True) diff --git a/tests/test_recording.py b/tests/test_recording.py new file mode 100644 index 0000000..74141e7 --- /dev/null +++ b/tests/test_recording.py @@ -0,0 +1,131 @@ +"""Tests for screen recording helpers (iOS + Android). + +Mocks the appium() boundary so tests run without a real device. +""" +import base64 + +import pytest + +from iphone_harness import helpers as iph +from android_harness import helpers as anh + + +# ---- iOS ------------------------------------------------------------------ + +def test_iph_record_screen_writes_decoded_mp4(monkeypatch, tmp_path): + fake_video = b"\x00\x01\x02" * 100 # fake mp4 bytes + encoded = base64.b64encode(fake_video).decode() + + calls = [] + def fake_appium(script, **kw): + calls.append((script, kw)) + if script == "mobile: stopRecordingScreen": + return encoded + return None + + monkeypatch.setattr(iph, "appium", fake_appium) + monkeypatch.setattr(iph.time, "sleep", lambda *a: None) + + out = iph.record_screen(duration=1, path=str(tmp_path / "out.mp4")) + assert out.endswith("out.mp4") + assert (tmp_path / "out.mp4").read_bytes() == fake_video + + scripts = [c[0] for c in calls] + assert "mobile: startRecordingScreen" in scripts + assert "mobile: stopRecordingScreen" in scripts + + +def test_iph_record_screen_default_path(monkeypatch): + encoded = base64.b64encode(b"").decode() + monkeypatch.setattr(iph, "appium", lambda script, **kw: encoded if "stop" in script else None) + monkeypatch.setattr(iph.time, "sleep", lambda *a: None) + out = iph.record_screen(duration=1) + assert "iph-record-" in out + assert out.endswith(".mp4") + + +def test_iph_record_screen_raises_on_start_failure(monkeypatch): + def fake_appium(script, **kw): + if script == "mobile: startRecordingScreen": + raise RuntimeError("xcuitest doesn't support") + return None + monkeypatch.setattr(iph, "appium", fake_appium) + with pytest.raises(RuntimeError, match="start screen recording failed"): + iph.record_screen(duration=1) + + +def test_iph_has_stop_screen_recording(): + """Existing iOS stop_screen_recording stays (UI-driven path).""" + assert callable(iph.stop_screen_recording) + + +# ---- Android -------------------------------------------------------------- + +def test_anh_record_screen_writes_decoded_mp4(monkeypatch, tmp_path): + fake_video = b"\xff" * 256 + encoded = base64.b64encode(fake_video).decode() + + calls = [] + def fake_appium(script, **kw): + calls.append((script, kw)) + if script == "mobile: stopRecordingScreen": + return encoded + return None + + monkeypatch.setattr(anh, "appium", fake_appium) + monkeypatch.setattr(anh.time, "sleep", lambda *a: None) + + out = anh.record_screen(duration=1, path=str(tmp_path / "v.mp4")) + assert (tmp_path / "v.mp4").read_bytes() == fake_video + + +def test_anh_record_screen_rejects_over_180s(monkeypatch): + monkeypatch.setattr(anh, "appium", lambda *a, **kw: None) + with pytest.raises(RuntimeError, match="180s per segment"): + anh.record_screen(duration=200) + + +def test_anh_record_screen_passes_bit_rate(monkeypatch): + captured = {} + def fake_appium(script, **kw): + if script == "mobile: startRecordingScreen": + captured.update(kw) + return base64.b64encode(b"").decode() if "stop" in script else None + monkeypatch.setattr(anh, "appium", fake_appium) + monkeypatch.setattr(anh.time, "sleep", lambda *a: None) + anh.record_screen(duration=1, bit_rate="8M") + assert captured["bitRate"] == "8M" + + +def test_anh_record_screen_passes_size(monkeypatch): + captured = {} + def fake_appium(script, **kw): + if script == "mobile: startRecordingScreen": + captured.update(kw) + return base64.b64encode(b"").decode() if "stop" in script else None + monkeypatch.setattr(anh, "appium", fake_appium) + monkeypatch.setattr(anh.time, "sleep", lambda *a: None) + anh.record_screen(duration=1, size="720x1280") + assert captured["videoSize"] == "720x1280" + + +def test_anh_start_stop_separate(monkeypatch, tmp_path): + fake_video = b"data" + encoded = base64.b64encode(fake_video).decode() + def fake_appium(script, **kw): + return encoded if "stop" in script else None + monkeypatch.setattr(anh, "appium", fake_appium) + anh.start_screen_recording() + out = anh.stop_screen_recording(path=str(tmp_path / "split.mp4")) + assert (tmp_path / "split.mp4").read_bytes() == fake_video + + +def test_anh_has_record_screen(): + assert callable(anh.record_screen) + assert callable(anh.start_screen_recording) + assert callable(anh.stop_screen_recording) + + +def test_both_platforms_export_record_screen(): + assert callable(iph.record_screen) + assert callable(anh.record_screen) diff --git a/tests/test_recovery.py b/tests/test_recovery.py new file mode 100644 index 0000000..cadbebf --- /dev/null +++ b/tests/test_recovery.py @@ -0,0 +1,179 @@ +"""Device disconnect / sleep / lock recovery tests. + +Exercise the @retry_on_disconnect decorator and wake_device helper via +boundary-mocked _send. No real device or Appium needed. +""" +from unittest import mock + +import pytest + +from iphone_harness import helpers as iph +from android_harness import helpers as anh + + +# ---- @retry_on_disconnect (iOS) ------------------------------------------- + +def test_iph_retry_succeeds_after_transient_disconnect(monkeypatch): + calls = {"n": 0} + + def flaky(): + calls["n"] += 1 + if calls["n"] < 3: + raise RuntimeError("daemon unreachable") + return "ok" + + # Skip daemon restart/wake side-effects in tests + monkeypatch.setattr(iph, "wake_device", lambda: False) + from iphone_harness import admin as iph_admin + monkeypatch.setattr(iph_admin, "restart_daemon", lambda *a, **kw: None) + monkeypatch.setattr(iph_admin, "ensure_daemon", lambda *a, **kw: None) + + wrapped = iph.retry_on_disconnect(max_attempts=3, backoff=0.01)(flaky) + assert wrapped() == "ok" + assert calls["n"] == 3 + + +def test_iph_retry_raises_disconnect_error_after_max_attempts(monkeypatch): + monkeypatch.setattr(iph, "wake_device", lambda: False) + from iphone_harness import admin as iph_admin + monkeypatch.setattr(iph_admin, "restart_daemon", lambda *a, **kw: None) + monkeypatch.setattr(iph_admin, "ensure_daemon", lambda *a, **kw: None) + + def always_dies(): + raise RuntimeError("connection lost") + + wrapped = iph.retry_on_disconnect(max_attempts=2, backoff=0.01)(always_dies) + with pytest.raises(iph.DeviceDisconnectError): + wrapped() + + +def test_iph_retry_does_not_swallow_unrelated_errors(monkeypatch): + monkeypatch.setattr(iph, "wake_device", lambda: False) + + def bad(): + raise RuntimeError("invalid argument") # NOT a disconnect pattern + + wrapped = iph.retry_on_disconnect(max_attempts=3, backoff=0.01)(bad) + with pytest.raises(RuntimeError, match="invalid argument"): + wrapped() + + +def test_iph_retry_preserves_function_metadata(): + @iph.retry_on_disconnect(max_attempts=2) + def my_named_function(): + """docstring""" + return 1 + + assert my_named_function.__name__ == "my_named_function" + assert my_named_function.__doc__ == "docstring" + + +def test_iph_is_locked_handles_exception(monkeypatch): + """is_locked() should return False rather than raising if appium call fails.""" + def boom(*a, **kw): + raise RuntimeError("appium not available") + monkeypatch.setattr(iph, "appium", boom) + assert iph.is_locked() is False + + +def test_iph_wake_device_noop_when_unlocked(monkeypatch): + """Already-unlocked returns True (post-state confirmed unlocked).""" + monkeypatch.setattr(iph, "is_locked", lambda: False) + assert iph.wake_device() is True + + +def test_iph_wake_device_calls_unlock_when_locked(monkeypatch): + """Locked → unlock called → post-state checked → True iff unlocked after.""" + states = iter([True, False]) # locked at start, unlocked after unlock() + monkeypatch.setattr(iph, "is_locked", lambda: next(states)) + called = {} + + def fake_appium(script, **kw): + called["script"] = script + return None + + monkeypatch.setattr(iph, "appium", fake_appium) + assert iph.wake_device() is True + assert called["script"] == "mobile: unlock" + + +# ---- @retry_on_disconnect (Android) --------------------------------------- + +def test_anh_retry_succeeds_after_transient_disconnect(monkeypatch): + calls = {"n": 0} + + def flaky(): + calls["n"] += 1 + if calls["n"] < 3: + raise RuntimeError("adb session dropped") + return "ok" + + monkeypatch.setattr(anh, "wake_device", lambda: False) + from android_harness import admin as anh_admin + monkeypatch.setattr(anh_admin, "restart_daemon", lambda *a, **kw: None) + monkeypatch.setattr(anh_admin, "ensure_daemon", lambda *a, **kw: None) + + wrapped = anh.retry_on_disconnect(max_attempts=3, backoff=0.01)(flaky) + assert wrapped() == "ok" + assert calls["n"] == 3 + + +def test_anh_retry_raises_disconnect_error_after_max_attempts(monkeypatch): + monkeypatch.setattr(anh, "wake_device", lambda: False) + from android_harness import admin as anh_admin + monkeypatch.setattr(anh_admin, "restart_daemon", lambda *a, **kw: None) + monkeypatch.setattr(anh_admin, "ensure_daemon", lambda *a, **kw: None) + + def always_dies(): + raise RuntimeError("uiautomator2 timed out") + + wrapped = anh.retry_on_disconnect(max_attempts=2, backoff=0.01)(always_dies) + with pytest.raises(anh.DeviceDisconnectError): + wrapped() + + +def test_anh_retry_does_not_swallow_unrelated_errors(monkeypatch): + monkeypatch.setattr(anh, "wake_device", lambda: False) + + def bad(): + raise RuntimeError("permission denied") + + wrapped = anh.retry_on_disconnect(max_attempts=3, backoff=0.01)(bad) + with pytest.raises(RuntimeError, match="permission denied"): + wrapped() + + +def test_anh_is_locked_handles_exception(monkeypatch): + def boom(*a, **kw): + raise RuntimeError("appium not available") + monkeypatch.setattr(anh, "appium", boom) + assert anh.is_locked() is False + + +def test_anh_wake_device_noop_when_unlocked(monkeypatch): + """Already-unlocked → True (no work needed; post-state is unlocked).""" + monkeypatch.setattr(anh, "is_locked", lambda: False) + assert anh.wake_device() is True + + +def test_anh_wake_device_calls_unlock_when_locked(monkeypatch): + states = iter([True, False]) # locked → unlock → unlocked + monkeypatch.setattr(anh, "is_locked", lambda: next(states)) + called = {} + + def fake_appium(script, **kw): + called["script"] = script + return None + + monkeypatch.setattr(anh, "appium", fake_appium) + assert anh.wake_device() is True + assert called["script"] == "mobile: unlock" + + +def test_both_platforms_export_recovery_api(): + """Public API is consistent across iOS and Android.""" + for mod in (iph, anh): + assert callable(getattr(mod, "retry_on_disconnect")) + assert callable(getattr(mod, "wake_device")) + assert callable(getattr(mod, "is_locked")) + assert issubclass(getattr(mod, "DeviceDisconnectError"), RuntimeError) diff --git a/tests/test_remote_daemon.py b/tests/test_remote_daemon.py new file mode 100644 index 0000000..236cc65 --- /dev/null +++ b/tests/test_remote_daemon.py @@ -0,0 +1,160 @@ +"""Client-only / remote-daemon mode tests. + +Covers the path a Windows or Linux host takes when driving iOS via a remote +Mac (or Android via remote anywhere): IPH_CONNECT / ANH_CONNECT pointed at +tcp://:port causes admin.ensure_daemon to skip local-spawn entirely +and raise a remediation if the remote daemon is unreachable. + +Cross-references with test_ipc_tcp.py (transport-level) and test_cli_dispatch.py +(CLI flag wiring). This file is exclusively the client-mode policy layer. +""" +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from iphone_harness import admin as iph_admin +from android_harness import admin as anh_admin + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +# ---- is_remote_daemon() policy ------------------------------------------- + +def test_is_remote_daemon_unset_is_false(monkeypatch): + monkeypatch.delenv("IPH_CONNECT", raising=False) + monkeypatch.delenv("ANH_CONNECT", raising=False) + assert iph_admin.is_remote_daemon() is False + assert anh_admin.is_remote_daemon() is False + + +def test_is_remote_daemon_tcp_is_true(monkeypatch): + monkeypatch.setenv("IPH_CONNECT", "tcp://192.168.1.10:8763") + monkeypatch.setenv("ANH_CONNECT", "tcp://192.168.1.10:8764") + assert iph_admin.is_remote_daemon() is True + assert anh_admin.is_remote_daemon() is True + + +def test_is_remote_daemon_loopback_tcp_still_true(monkeypatch): + """tcp://127.0.0.1 = SSH-tunnel pattern — also client-only mode.""" + monkeypatch.setenv("IPH_CONNECT", "tcp://127.0.0.1:8763") + assert iph_admin.is_remote_daemon() is True + + +def test_is_remote_daemon_unix_is_false(monkeypatch): + monkeypatch.setenv("IPH_CONNECT", "unix:/tmp/iph-default.sock") + assert iph_admin.is_remote_daemon() is False + + +def test_is_remote_daemon_malformed_uri_is_false(monkeypatch): + """Malformed IPH_CONNECT shouldn't accidentally trip client-only mode.""" + monkeypatch.setenv("IPH_CONNECT", "not a uri") + assert iph_admin.is_remote_daemon() is False + + +# ---- ensure_daemon in client_mode never spawns --------------------------- + +def test_client_mode_ensure_daemon_raises_remediation_iphone(monkeypatch): + """When IPH_CONNECT points at an unreachable TCP daemon, ensure_daemon + must NOT try to spawn locally — it raises a remote-side checklist.""" + monkeypatch.setenv("IPH_CONNECT", "tcp://127.0.0.1:1") # port 1 = guaranteed unreachable + monkeypatch.delenv("IPH_BIND", raising=False) + with pytest.raises(RuntimeError) as exc: + iph_admin.ensure_daemon(wait=1.0) + msg = str(exc.value) + assert "remote daemon unreachable" in msg + assert "client-only mode" in msg + assert "ssh -L" in msg or "ssh mac" in msg + + +def test_client_mode_ensure_daemon_raises_remediation_android(monkeypatch): + monkeypatch.setenv("ANH_CONNECT", "tcp://127.0.0.1:1") + monkeypatch.delenv("ANH_BIND", raising=False) + with pytest.raises(RuntimeError) as exc: + anh_admin.ensure_daemon(wait=1.0) + msg = str(exc.value) + assert "remote daemon unreachable" in msg + assert "client-only mode" in msg + + +def test_client_mode_ensure_daemon_does_not_spawn_subprocess(monkeypatch): + """Belt-and-suspenders: ensure no `Popen` is called in client-only mode.""" + monkeypatch.setenv("IPH_CONNECT", "tcp://127.0.0.1:1") + monkeypatch.delenv("IPH_BIND", raising=False) + called = {"popen": 0} + real_popen = subprocess.Popen + + def _spy(*a, **kw): + called["popen"] += 1 + return real_popen(*a, **kw) + + monkeypatch.setattr("iphone_harness.admin.subprocess.Popen", _spy) + with pytest.raises(RuntimeError): + iph_admin.ensure_daemon(wait=1.0) + assert called["popen"] == 0 + + +# ---- CLI --remote-daemon flag wiring ------------------------------------- + +def _run_cli(args, env_extra=None): + """Run `mobile-use ` as a subprocess; return (rc, stdout, stderr).""" + env = {**os.environ} + if env_extra: + env.update(env_extra) + p = subprocess.run( + [sys.executable, "-m", "mobile_use.cli", *args], + env=env, capture_output=True, text=True, timeout=15.0, + cwd=str(REPO_ROOT), + ) + return p.returncode, p.stdout, p.stderr + + +def test_cli_help_advertises_remote_daemon(): + """`mobile-use --help` must show the --remote-daemon flag (verify hook).""" + rc, out, _ = _run_cli(["--help"]) + assert rc == 0 + assert "--remote-daemon" in out + + +def test_cli_help_advertises_headed_flag(): + rc, out, _ = _run_cli(["--help"]) + assert rc == 0 + assert "--headed" in out + assert "--headless" in out + + +def test_cli_remote_daemon_invalid_uri_exits(): + """Bad URI should fail fast with a clear message (not 'remote daemon unreachable').""" + rc, out, err = _run_cli(["--ios", "--remote-daemon", "not_a_uri", "-c", "pass"]) + assert rc != 0 + combined = out + err + assert "Invalid --remote-daemon URI" in combined or "Invalid" in combined + + +# ---- _platform helpers --------------------------------------------------- + +def test_platform_is_windows_only_on_win32(monkeypatch): + """is_windows() reflects sys.platform; not host-detected via shell.""" + from mobile_use import _platform + # Use the actual sys.platform — the function is a thin wrapper. + import sys as _sys + assert _platform.is_windows() is (_sys.platform == "win32") + + +def test_platform_needs_remote_mac_for_ios_on_non_darwin(): + """On macOS, needs_remote_mac_for_ios() is False; elsewhere True.""" + from mobile_use import _platform + import sys as _sys + expected = _sys.platform != "darwin" + assert _platform.needs_remote_mac_for_ios() is expected + + +def test_platform_windows_ios_setup_hint_mentions_remote_daemon(): + from mobile_use import _platform + hint = _platform.windows_ios_setup_hint() + assert "--remote-daemon" in hint + assert "tcp://" in hint + assert "Mac" in hint diff --git a/tests/test_screen_stream.py b/tests/test_screen_stream.py new file mode 100644 index 0000000..d50bca8 --- /dev/null +++ b/tests/test_screen_stream.py @@ -0,0 +1,238 @@ +"""Screen stream RPC tests — exercises screen_stream_{start,frame,stop} via the +mock iphone + android daemons. The mock returns a synthetic 67-byte JPEG stub +so tests don't need PIL/a real device. +""" +import base64 +import os +import subprocess +import sys +import time +import uuid +from pathlib import Path + +import pytest + +from iphone_harness import _ipc as iph_ipc +from android_harness import _ipc as anh_ipc + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _wait_alive(ipc_mod, name, timeout=5.0): + deadline = time.time() + timeout + while time.time() < deadline: + if ipc_mod.ping(name, timeout=0.3): + return True + time.sleep(0.05) + return False + + +def _spawn_mock(platform, name): + if platform == "iphone": + module = "tests._mock_iphone_daemon" + env_var = "IPH_NAME" + else: + module = "tests._mock_android_daemon" + env_var = "ANH_NAME" + env = {**os.environ, env_var: name} + return subprocess.Popen( + [sys.executable, "-m", module], + env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + cwd=str(REPO_ROOT), start_new_session=True, + ) + + +def _cleanup(platform, name): + prefix = "iph" if platform == "iphone" else "anh" + for ext in ("sock", "pid", "log"): + try: + (Path("/tmp") / f"{prefix}-{name}.{ext}").unlink() + except FileNotFoundError: + pass + + +@pytest.fixture +def iph_name(): + n = f"tst{uuid.uuid4().hex[:10]}" + os.environ["IPH_NAME"] = n + yield n + _cleanup("iphone", n) + os.environ.pop("IPH_NAME", None) + + +@pytest.fixture +def anh_name(): + n = f"tst{uuid.uuid4().hex[:10]}" + os.environ["ANH_NAME"] = n + yield n + _cleanup("android", n) + os.environ.pop("ANH_NAME", None) + + +@pytest.fixture +def iph_daemon(iph_name): + p = _spawn_mock("iphone", iph_name) + if not _wait_alive(iph_ipc, iph_name, timeout=5.0): + p.kill(); p.wait(timeout=2.0) + pytest.fail("mock iphone daemon never came up") + yield iph_name, p + try: + s, _ = iph_ipc.connect(iph_name, timeout=1.0) + iph_ipc.request(s, None, {"meta": "shutdown"}) + s.close() + except Exception: + pass + try: + p.wait(timeout=3.0) + except subprocess.TimeoutExpired: + p.kill(); p.wait(timeout=2.0) + + +@pytest.fixture +def anh_daemon(anh_name): + p = _spawn_mock("android", anh_name) + if not _wait_alive(anh_ipc, anh_name, timeout=5.0): + p.kill(); p.wait(timeout=2.0) + pytest.fail("mock android daemon never came up") + yield anh_name, p + try: + s, _ = anh_ipc.connect(anh_name, timeout=1.0) + anh_ipc.request(s, None, {"meta": "shutdown"}) + s.close() + except Exception: + pass + try: + p.wait(timeout=3.0) + except subprocess.TimeoutExpired: + p.kill(); p.wait(timeout=2.0) + + +def _call(ipc_mod, name, method, params=None): + s, t = ipc_mod.connect(name, timeout=1.0) + try: + return ipc_mod.request(s, t, {"method": method, "params": params or {}}) + finally: + s.close() + + +# ---- iPhone screen_stream RPC -------------------------------------------- + +def test_screen_stream_start_iphone(iph_daemon): + name, _ = iph_daemon + r = _call(iph_ipc, name, "screen_stream_start", {"fps": 4, "quality": 70}) + assert r["result"]["running"] is True + assert r["result"]["started"] is True + assert r["result"]["fps"] == 4 + assert r["result"]["quality"] == 70 + + +def test_screen_stream_frame_not_ready_until_start_iphone(iph_daemon): + name, _ = iph_daemon + r = _call(iph_ipc, name, "screen_stream_frame", {}) + assert r["result"]["ready"] is False + assert r["result"]["frame_no"] == 0 + + +def test_screen_stream_frame_after_start_iphone(iph_daemon): + name, _ = iph_daemon + _call(iph_ipc, name, "screen_stream_start", {}) + r = _call(iph_ipc, name, "screen_stream_frame", {}) + assert r["result"]["ready"] is True + assert r["result"]["frame_no"] >= 1 + jpeg = base64.b64decode(r["result"]["jpeg_b64"]) + # JPEG SOI marker — 0xff 0xd8 first two bytes. + assert jpeg[:2] == b"\xff\xd8" + + +def test_screen_stream_frame_no_increments_iphone(iph_daemon): + name, _ = iph_daemon + _call(iph_ipc, name, "screen_stream_start", {}) + a = _call(iph_ipc, name, "screen_stream_frame", {})["result"]["frame_no"] + b = _call(iph_ipc, name, "screen_stream_frame", {})["result"]["frame_no"] + assert b > a + + +def test_screen_stream_reconfigure_iphone(iph_daemon): + """Calling start again returns updated=True, not started=True.""" + name, _ = iph_daemon + _call(iph_ipc, name, "screen_stream_start", {"fps": 4}) + r = _call(iph_ipc, name, "screen_stream_start", {"fps": 10, "quality": 80}) + assert r["result"]["running"] is True + assert r["result"]["updated"] is True + assert r["result"]["started"] is False + assert r["result"]["fps"] == 10 + + +def test_screen_stream_stop_iphone(iph_daemon): + name, _ = iph_daemon + _call(iph_ipc, name, "screen_stream_start", {}) + r = _call(iph_ipc, name, "screen_stream_stop", {}) + assert r["result"]["running"] is False + assert r["result"]["stopped"] is True + # After stop, frame is no longer ready. + r2 = _call(iph_ipc, name, "screen_stream_frame", {}) + assert r2["result"]["ready"] is False + + +def test_screen_stream_stop_idempotent_iphone(iph_daemon): + name, _ = iph_daemon + r = _call(iph_ipc, name, "screen_stream_stop", {}) + assert r["result"]["running"] is False + + +# ---- Android screen_stream RPC ------------------------------------------ + +def test_screen_stream_start_android(anh_daemon): + name, _ = anh_daemon + r = _call(anh_ipc, name, "screen_stream_start", {"fps": 6}) + assert r["result"]["running"] is True + assert r["result"]["fps"] == 6 + + +def test_screen_stream_frame_after_start_android(anh_daemon): + name, _ = anh_daemon + _call(anh_ipc, name, "screen_stream_start", {}) + r = _call(anh_ipc, name, "screen_stream_frame", {}) + assert r["result"]["ready"] is True + jpeg = base64.b64decode(r["result"]["jpeg_b64"]) + assert jpeg[:2] == b"\xff\xd8" + + +def test_screen_stream_stop_android(anh_daemon): + name, _ = anh_daemon + _call(anh_ipc, name, "screen_stream_start", {}) + r = _call(anh_ipc, name, "screen_stream_stop", {}) + assert r["result"]["running"] is False + + +# ---- helpers wrappers ---------------------------------------------------- + +def test_stream_rpc_helpers_iphone(iph_daemon, monkeypatch): + """iphone_harness.helpers exposes screen_stream_start/frame/stop wrappers.""" + name, _ = iph_daemon + import iphone_harness.helpers as h + # helpers.NAME is captured at import time; tests use unique names per run. + monkeypatch.setattr(h, "NAME", name) + h._drop_conn() + started = h.screen_stream_start(fps=4, quality=50) + assert started["running"] is True + frame = h.screen_stream_frame() + assert frame["ready"] is True + assert frame["frame_no"] >= 1 + stopped = h.screen_stream_stop() + assert stopped["running"] is False + + +def test_stream_rpc_helpers_android(anh_daemon, monkeypatch): + name, _ = anh_daemon + import android_harness.helpers as h + monkeypatch.setattr(h, "NAME", name) + h._drop_conn() + started = h.screen_stream_start(fps=4) + assert started["running"] is True + frame = h.screen_stream_frame() + assert frame["ready"] is True + stopped = h.screen_stream_stop() + assert stopped["running"] is False diff --git a/tests/test_viewer_mjpeg.py b/tests/test_viewer_mjpeg.py new file mode 100644 index 0000000..61d3efa --- /dev/null +++ b/tests/test_viewer_mjpeg.py @@ -0,0 +1,281 @@ +"""HTTP MJPEG viewer sidecar tests. + +Spawns the mock iphone (or android) daemon, points the ViewerServer at it, +then hits the HTTP endpoints with urllib. Verifies: + - index.html served on / + - /healthz returns JSON with platform + frame_no + - /still returns a JPEG (synthetic stub from mock) + - /stream returns multipart/x-mixed-replace with at least one frame + - start/stop lifecycle releases the port +""" +import json +import os +import subprocess +import sys +import time +import urllib.request +import uuid +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _wait_alive(ipc_mod, name, timeout=5.0): + deadline = time.time() + timeout + while time.time() < deadline: + if ipc_mod.ping(name, timeout=0.3): + return True + time.sleep(0.05) + return False + + +def _spawn_mock(platform, name): + if platform == "iphone": + module = "tests._mock_iphone_daemon" + env_var = "IPH_NAME" + else: + module = "tests._mock_android_daemon" + env_var = "ANH_NAME" + return subprocess.Popen( + [sys.executable, "-m", module], + env={**os.environ, env_var: name}, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + cwd=str(REPO_ROOT), start_new_session=True, + ) + + +def _cleanup(platform, name): + prefix = "iph" if platform == "iphone" else "anh" + for ext in ("sock", "pid", "log"): + try: + (Path("/tmp") / f"{prefix}-{name}.{ext}").unlink() + except FileNotFoundError: + pass + + +@pytest.fixture +def iph_viewer(monkeypatch): + """Mock iphone daemon + ViewerServer wired together, ready to hit via HTTP.""" + from iphone_harness import _ipc as ipc + import iphone_harness.helpers as ih + from mobile_use.viewer.server import ViewerServer + + name = f"tst{uuid.uuid4().hex[:10]}" + monkeypatch.setenv("IPH_NAME", name) + monkeypatch.setattr(ih, "NAME", name) + p = _spawn_mock("iphone", name) + if not _wait_alive(ipc, name, timeout=5.0): + p.kill(); p.wait(timeout=2.0); _cleanup("iphone", name) + pytest.fail("mock iphone daemon never came up") + v = ViewerServer(platform="ios", fps=8, quality=70) + v.start() + try: + yield v + finally: + v.stop() + try: + s, _ = ipc.connect(name, timeout=1.0) + ipc.request(s, None, {"meta": "shutdown"}) + s.close() + except Exception: + pass + try: + p.wait(timeout=3.0) + except subprocess.TimeoutExpired: + p.kill(); p.wait(timeout=2.0) + _cleanup("iphone", name) + + +@pytest.fixture +def anh_viewer(monkeypatch): + from android_harness import _ipc as ipc + import android_harness.helpers as ah + from mobile_use.viewer.server import ViewerServer + + name = f"tst{uuid.uuid4().hex[:10]}" + monkeypatch.setenv("ANH_NAME", name) + monkeypatch.setattr(ah, "NAME", name) + p = _spawn_mock("android", name) + if not _wait_alive(ipc, name, timeout=5.0): + p.kill(); p.wait(timeout=2.0); _cleanup("android", name) + pytest.fail("mock android daemon never came up") + v = ViewerServer(platform="android", fps=8) + v.start() + try: + yield v + finally: + v.stop() + try: + s, _ = ipc.connect(name, timeout=1.0) + ipc.request(s, None, {"meta": "shutdown"}) + s.close() + except Exception: + pass + try: + p.wait(timeout=3.0) + except subprocess.TimeoutExpired: + p.kill(); p.wait(timeout=2.0) + _cleanup("android", name) + + +# ---- import sanity -------------------------------------------------------- + +def test_viewer_import_ok(): + from mobile_use.viewer.server import ViewerServer + assert ViewerServer is not None + + +def test_viewer_init_picks_free_port(): + from mobile_use.viewer.server import ViewerServer + v = ViewerServer(platform="ios") + assert 0 < v.port < 65536 + assert v.url == f"http://127.0.0.1:{v.port}/" + + +def test_viewer_rejects_unknown_platform(): + from mobile_use.viewer.server import ViewerServer + with pytest.raises(ValueError): + ViewerServer(platform="windows-phone") + + +# ---- HTTP routes --------------------------------------------------------- + +def test_viewer_mjpeg_index_served(iph_viewer): + with urllib.request.urlopen(iph_viewer.url, timeout=2.0) as r: + assert r.status == 200 + body = r.read() + assert b"mobile-use" in body + assert b"