diff --git a/Dockerfile b/Dockerfile index 52394068..97e413b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,4 +22,4 @@ RUN uv sync --locked --no-dev --no-editable --extra anthropic --no-cache ENTRYPOINT [".venv/bin/mobilerun"] -CMD ["setup"] +CMD ["device", "setup"] diff --git a/README.md b/README.md index 4acfa708..77c2fd2a 100644 --- a/README.md +++ b/README.md @@ -72,77 +72,339 @@ Use the framework when you want to run the agent on your machine. Use [Mobilerun ## 📦 Installation -> **Note:** Python 3.14 is not currently supported. Please use Python `>=3.11,<3.14`. +Use Python 3.11–3.13. Python 3.14 is not supported. +Install the CLI with [`uv`](https://docs.astral.sh/uv/): -Install Mobilerun with [`uv`](https://docs.astral.sh/uv/): +```bash +uv tool install --python 3.13 mobilerun +``` + +For Anthropic models, include the optional extra: ```bash -# CLI usage -uv tool install mobilerun +uv tool install --force --python 3.13 "mobilerun[anthropic]" ``` +For Python integration, install into a virtual environment instead: + ```bash -# CLI + Python integration +uv venv --python 3.13 uv pip install mobilerun ``` -Most LLM providers are included by default. For Anthropic support, install the optional extra: +## 🚀 Quickstart: local Android + +Install [Android SDK Platform-Tools](https://developer.android.com/studio/releases/platform-tools) +so `adb` is on your PATH. Connect one device using either QR pairing or USB. + +**QR pairing:** on Android 11 or later, put the device and computer on the same Wi-Fi. +Open Developer options → Wireless debugging → "Pair device with QR code", then scan: + +```bash +mobilerun device pair +``` + +Keep this command in a terminal. It prints the ready device serial after adb +connects. The timeout is 120 seconds; `--timeout SECONDS` changes it. + +**USB:** enable Developer options and USB debugging, connect the device, and approve +its debugging prompt. Check the connection: ```bash -uv tool install "mobilerun[anthropic]" +mobilerun device list ``` -## 🚀 Quickstart +Install the Portal, check device access, configure an LLM provider, and run a task: ```bash -uv tool install mobilerun -mobilerun setup +mobilerun device setup +mobilerun device ping mobilerun configure -mobilerun run "Open settings and turn on dark mode" +mobilerun run "Open Settings and report the Android version" ``` -Before starting, make sure you have [ADB](https://developer.android.com/studio/releases/platform-tools) installed and an Android device with Developer options and USB debugging enabled. For iOS, set up [`mobilerun-ios`](https://docs.mobilerun.ai/framework/guides/device-setup) for an iPhone or the [iOS Portal](https://github.com/droidrun/ios-portal) app for the simulator. +The setup command installs the Portal and enables its accessibility service. +The configure wizard selects a provider, authentication method, and model. +If more than one device is connected, pass `-d DEVICE_SERIAL` to each command +that selects a device. Replace uppercase placeholders with your own values. -### 1. Install the Portal on your device +For iOS, follow the [device setup guide](https://docs.mobilerun.ai/framework/guides/device-setup) +for `mobilerun-ios` over USB or the iOS Portal for the simulator. + +### Quickstart: cloud + +Use an existing device in [Mobilerun Cloud](https://cloud.mobilerun.ai). +Sign in and copy its id from the listing: ```bash -mobilerun setup +mobilerun auth login +mobilerun device list -t cloud ``` -This installs the Mobilerun Portal app, enables its accessibility service, and prepares the device for local control. +Sign-in prints a QR code on terminal stderr, then a complete sign-in link, +then the short link and user code for manual entry. Scan the QR or click the +complete link to approve access. The browser stays closed unless you pass +`mobilerun auth login --browser`. `--no-browser` is accepted for Mobilerun +but is unnecessary; it still controls provider sign-ins. Terminal links use +clickable hyperlinks; redirected output and `NO_COLOR` contain no escape +sequences. This cloud sign-in is separate from the LLM provider configured above. -### 2. Verify the connection +Set the target and default device, replacing the placeholder with the listed id: ```bash -mobilerun ping +export MOBILERUN_TARGET=cloud +export MOBILERUN_DEVICE="CLOUD_DEVICE_ID" +mobilerun run "Open Settings and report the Android version" -d "$MOBILERUN_DEVICE" ``` -You should see confirmation that the Portal is installed and accessible. +The environment values also apply to later commands, so you can omit the device +flag after setting the default. An explicit device selects its backend: a cloud +id, an ADB serial, or a Portal HTTP URL. Without a device, the local target +selects an attached device; the cloud target requires an id. + +### Command tree + +```text +mobilerun +├── run TASK +├── device +│ ├── list +│ ├── pair +│ ├── connect HOST:PORT +│ ├── disconnect HOST:PORT +│ ├── setup +│ ├── ping +│ ├── tap X Y +│ ├── swipe X1 Y1 X2 Y2 +│ ├── long-press X Y +│ ├── type TEXT +│ ├── press BUTTON +│ ├── screenshot +│ ├── ui +│ ├── apps +│ └── start PACKAGE +├── configure +├── auth +│ ├── login [SERVICE] +│ ├── logout [SERVICE] +│ └── status +├── doctor +├── env +├── macro +│ ├── replay PATH +│ └── list [DIRECTORY] +└── completion SHELL +``` -### 3. Configure your LLM provider +The sign-in services are `mobilerun` (default), `openai`, `anthropic`, `gemini`, +and `xai`. Provider sign-ins use the same auth group; the configure command +runs the wizard. The `configure` provider choices include only supported +providers; `run` and `macro replay` also accept `openrouter` and `deepseek`. +Help includes examples. Unknown commands suggest full paths across the command +tree, including renamed commands and spelling corrections, without executing them. +The `run --agent` option appears only when an external agent is installed. +See each command's help for its arguments and options. + +### Shared flags + +A short letter has one meaning. Options apply only to commands that list them +in help; local pairing and TCP connect/disconnect do not accept device-selection +flags. In the table, **device actions** means tap, swipe, long-press, type, press, +screenshot, ui, apps, start, and ping. + +| Short | Long | Commands / meaning | +| --- | --- | --- | +| `-h` | `--help` | Every command: show usage. | +| `-V` | `--version` | Root: show the version. | +| `-v` | `--verbose` | Every command: enable logs; off unless enabled in config or environment. | +| `-d` | `--device` | Device actions, setup, run, doctor, macro replay: device serial, Portal URL, or cloud id. | +| `-t` | `--target` | Device actions, list, run, doctor, macro replay: `local` (default) or `cloud`. | +| `-P` | `--platform` | Device actions, run, doctor, macro replay: `android` or `ios`; otherwise use config. | +| `-c` | `--config` | Device actions, setup, run, doctor, configure, env, macro replay: config file. | +| `-j` | `--json` | Device list/ui/apps/screenshot, auth status, doctor, env, macro list, run: one JSON document. | +| `-p` | `--provider` | Run, configure, macro replay: LLM provider. | +| `-m` | `--model` | Run, configure, macro replay, supported provider logins: LLM model. | +| `-u` | `--llm-url` | Run and configure: LLM endpoint, when supported by the provider. | +| `-s` | `--max-steps` | Run and macro replay: step limit. | +| `-T` | `--temperature` | Run: sampling temperature. | +| `-a` | `--agent` | Run: installed external agent. | +| `-r` | `--reasoning` / `--no-reasoning` | Run: enable or disable reasoning. | +| `-n` | `--dry-run` | Macro replay: show actions without executing them. | +| `-k` | `--api-key` | Configure: key for the selected LLM provider. | +| — | `--api-url` | Device actions, list, run, doctor, macro replay: cloud API endpoint. | +| — | `--auth-url` | Mobilerun login: sign-in endpoint. | +| — | `--tcp` / `--no-tcp` | Run and macro replay: local Android TCP mode. | +| — | `--browser` | Mobilerun login: open the sign-in link in the browser (off by default). | +| — | `--no-browser` | Provider logins: keep the browser closed; unnecessary for Mobilerun login. | +| — | `--no-delegate` | Run: let the decision model pick every action without LLM delegation. | +| — | `--delegate-model` | Run: helper LLM as `PROVIDER/MODEL` or `MODEL`. | +| — | `--no-qr` | Mobilerun login: suppress the QR code. | +| — | `--no-input` | Configure and auth login: never prompt for input. | + +`run -p TypeSafe` uses the Jev decision model and delegates to the configured LLM +by default. Its `-m` model is optional. `--no-delegate` disables delegation; +`--delegate-model PROVIDER/MODEL` or `--delegate-model MODEL` selects its helper LLM. ```bash -mobilerun configure +mobilerun run "Open Settings" -p TypeSafe --no-delegate +mobilerun run "Open Settings" -p TypeSafe --delegate-model openai/gpt-6.1-sol ``` -The wizard walks you through choosing a provider, auth method, and model. You can also use provider environment variables such as `GOOGLE_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `XAI_API_KEY`, or `MINIMAX_API_KEY`. +### Environment -### 4. Run your first command +For CLI options, flags override environment values, then config values and defaults. +`MOBILERUN_API_KEY` overrides a saved sign-in; unset it to use the sign-in. +The legacy `MOBILERUN_CLOUD_API_KEY` is still accepted when `MOBILERUN_API_KEY` is unset or empty. +Inspect resolved values and sources with `mobilerun env`; it shows secrets only +as `set` or `not set`. Provider authentication follows the selected provider's +configuration and credential store. `auth status` always checks the service +that issued the saved session, even when `MOBILERUN_AUTH_URL` has changed. + +| Variable | Meaning | Default / fallback | +| --- | --- | --- | +| `MOBILERUN_TARGET` | Default target | `local` | +| `MOBILERUN_DEVICE` | Default device serial, Portal URL, or cloud id | Configured serial, then local selection | +| `MOBILERUN_API_KEY` | Cloud authentication | Saved Mobilerun sign-in credential | +| `MOBILERUN_API_BASE_URL` | Cloud API endpoint | `https://api.mobilerun.ai/v1` | +| `MOBILERUN_AUTH_URL` | Mobilerun login endpoint | `https://cloud.mobilerun.ai/api/auth` | +| `MOBILERUN_CONFIG` | Config file | Platform user config directory | +| `MOBILERUN_ANDROID_PORTAL_TOKEN` | Android Portal HTTP token | Config token only when its platform is Android | +| `MOBILERUN_IOS_PORTAL_TOKEN` | iOS Portal HTTP token | Config token only when its platform is iOS | +| `MOBILERUN_DEBUG` | Verbose logs | Config, otherwise off | +| `MOBILERUN_TELEMETRY_ENABLED` | Anonymous telemetry | Enabled unless config or environment disables it | +| `MOBILERUN_STREAM_SCREENSHOTS` | Stream agent screenshots | Off | +| `MOBILERUN_OAUTH_MANUAL` | Use manual provider sign-in | Off; headless sessions may select a device flow | +| `ANDROID_SERIAL` | Local ADB selection fallback | No value | +| `NO_COLOR` | Disable colour; bold may remain | No value | +| `PHOENIX_URL` | Phoenix trace endpoint | `http://0.0.0.0:6006` | +| `PHOENIX_PROJECT_NAME` | Phoenix project | Empty | +| `GOOGLE_API_KEY`, `GEMINI_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `XAI_API_KEY`, `MINIMAX_API_KEY`, `ZAI_API_KEY` | LLM provider credentials | Selected provider configuration / saved credentials | +| `OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY` | Credentials for run-only providers | Provider defaults | + +### Scripting + +Stdout contains data. Progress and error messages go to stderr. +`device list`, `device apps`, and `macro list` print tab-separated rows; +a header row appears only when stdout is a terminal. Use `-j` for the stable +machine-readable form. `mobilerun env` prints `NAME=value (source)`, with empty +unset values, lowercase booleans, and secrets shown only as `set` or `not set`. +The text UI output has one tab-separated row per element: index, class, text, +resource id, bounds, and flags. Filter it with standard tools: ```bash -mobilerun run "Open the settings app and tell me the Android version" +mobilerun device ui | grep -i 'settings' +mobilerun device ui -j | jq '.a11y_tree' +mobilerun device list -t cloud -j | jq '.[].device_id' +``` + +Install `jq` to use the JSON examples. UI text uses the filtered tree; +`device ui --all` also includes empty layout containers. That option cannot +be combined with JSON output. + +Exit codes are **0** for success, **1** for failure, **2** for invalid usage, +and **130** for interruption. Read commands with JSON output return an error +document on failure; API errors include the HTTP status and an optional code: + +```json +{"error":"Resource not found","status":404,"code":null} ``` -Useful run options: +401/403 API errors also include `"credential":"env"` when the environment key +was used, or `"credential":"login"` when the saved sign-in was used. + +`doctor -j` retains completed checks, including when it exits 1. For example, +without Android SDK Platform-Tools (the Config path varies by platform): + +```json +{"ok":false,"checks":[{"name":"SDK Version","status":"warn","detail":"0.8.0 (could not check latest); GitHub API unreachable"},{"name":"Config","status":"pass","detail":"/home/user/.config/droidrun/config.yaml"},{"name":"ADB","status":"fail","detail":"adb was not found or is not running. Install Android SDK Platform-Tools (https://developer.android.com/studio/releases/platform-tools) and check `mobilerun doctor`."}]} +``` + +`device list --all` lists local devices first, then cloud devices. If either +listing fails, the other result is kept and a warning goes to stderr. It exits +1 only when both listings fail; a successful empty listing still counts as +success. With `-j`, it returns `{"devices":[...],"errors":[{"target":"cloud","error":"..."}]}`. +Listing a single target keeps the plain JSON list on success and fails normally +when that target is unavailable. +If adb is unavailable, local listing exits 1 with an Android SDK Platform-Tools +hint; it does not count as a successful empty listing. + +Other errors contain only `error`. Usage errors and interruptions may leave +stdout empty. The run command emits `success`, `reason`, and `steps` after a +task, including a failed task; errors before execution use the error document. +Check the command's exit status as well as its JSON. + +### Non-interactive use and secrets + +`configure` and `auth login` never prompt when stdin is not a terminal or +`--no-input` is supplied. Missing configuration options fail with exit 2 and +name the option to provide. For example, supply `-p`, `-m`, and a provider key +through its environment variable or stdin. An OAuth configuration needs an +existing sign-in when input is disabled. Prompt EOF exits 1; Ctrl-C exits 130. + +Use `-k -` or `--token -` to read one secret line from stdin. The trailing newline +is stripped, and empty input fails with exit 2. A literal flag value still works, +but it remains in shell history; prefer stdin or the provider's environment +variable. These examples use shell variables that are already set: ```bash -mobilerun run "Open settings and turn on dark mode" -mobilerun run "What app is currently open?" --vision -mobilerun run "Find a contact named John and send him an email" --reasoning -mobilerun run "Take a screenshot" --ios -mobilerun run "Open Settings" --steps 30 --debug +printf '%s\n' "$OPENAI_API_KEY" | mobilerun configure -p openai -m MODEL -k - --no-input +printf '%s\n' "$ANTHROPIC_SETUP_TOKEN" | mobilerun auth login anthropic --token - --no-input ``` +Device-code and browser-callback login flows can proceed without terminal input. +If a provider flow needs a pasted code, it fails before starting when input is +disabled. For Anthropic, supply `--token -`; for Gemini, run the login in a +terminal without `--no-input`: headless Gemini sign-in needs a terminal because +no option supplies its code. Tokens and keys are never included in CLI errors +or verbose logs. + +### Shell completion + +Print a completion script with `mobilerun completion bash|zsh|fish`. The command +writes only the script; it does not install anything. Add the matching line to +your shell configuration (Bash 4.4 or later is required for Bash completion): + +```bash +# ~/.bashrc +eval "$(mobilerun completion bash)" +``` + +```zsh +# ~/.zshrc (after compinit) +eval "$(mobilerun completion zsh)" +``` + +```fish +# ~/.config/fish/config.fish +mobilerun completion fish | source +``` + +### Upgrading from 0.7 + +The old spellings are removed; there are no aliases. Replace them as follows: + +| Before | After | +| --- | --- | +| `mobilerun devices` | `mobilerun device list` | +| `mobilerun connect SERIAL`, `mobilerun disconnect SERIAL` | `mobilerun device connect SERIAL`, `mobilerun device disconnect SERIAL` | +| `mobilerun setup`, `mobilerun ping` | `mobilerun device setup`, `mobilerun device ping` | +| `mobilerun login`, `mobilerun logout`, `mobilerun whoami` | `mobilerun auth login`, `mobilerun auth logout`, `mobilerun auth status` | +| `mobilerun configure openai`, `mobilerun configure anthropic`, `mobilerun configure gemini`, `mobilerun configure xai` | `mobilerun auth login SERVICE`; use `mobilerun configure -p PROVIDER` for the wizard | +| Device selection `--device-id` | `-d` / `--device` | +| Cloud `--base-url` | `--api-url` | +| `--cloud`, `--ios` | `-t cloud`, `-P ios` | +| `--debug` / `--no-debug` | `-v` / `--verbose`; omit to use config/default | +| Run `--base_url`, `--api_base` | `-u` / `--llm-url` | +| Run `--steps` | `-s` / `--max-steps` | +| Run `--control-backend`, `--device-id` | `--backend`, `--backend-device` for visual remote control | +| Run `--no-vision-only` | Omit `--vision-only` | +| Setup `--path`, `-pv` | `--apk`, `--portal-version` | +| Macro replay `-m` (step limit), `-t` (delay), `-s` (start step) | `-s` / `--max-steps`, `--delay`, `--start-from`; `-m` now selects a model | +| `DROIDRUN_CONFIG`, `DROIDRUN_TELEMETRY_ENABLED` | `MOBILERUN_CONFIG`, `MOBILERUN_TELEMETRY_ENABLED` | +| `DROIDRUN_STREAM_SCREENSHOTS`, `DROIDRUN_OAUTH_MANUAL` | `MOBILERUN_STREAM_SCREENSHOTS`, `MOBILERUN_OAUTH_MANUAL` | +| `MOBILERUN_DEVICE_TOKEN` | Platform-specific Portal token variables in the environment table | +| `phoenix_url`, `phoenix_project_name` | `PHOENIX_URL`, `PHOENIX_PROJECT_NAME` | + Read the full [framework documentation](https://docs.mobilerun.ai/framework/quickstart). ## ⚙️ Features diff --git a/compat/pyproject.toml b/compat/pyproject.toml index 46dba22a..68783337 100644 --- a/compat/pyproject.toml +++ b/compat/pyproject.toml @@ -1,16 +1,16 @@ [project] name = "droidrun" -version = "0.7.4" +version = "0.8.0" description = "Compatibility shim — droidrun has been renamed to mobilerun" requires-python = ">=3.11,<3.14" -dependencies = ["mobilerun==0.7.4"] +dependencies = ["mobilerun==0.8.0"] [project.optional-dependencies] -anthropic = ["mobilerun[anthropic]==0.7.4"] -deepseek = ["mobilerun[deepseek]==0.7.4"] -langfuse = ["mobilerun[langfuse]==0.7.4"] -typesafe = ["mobilerun[typesafe]==0.7.4"] -dev = ["mobilerun[dev]==0.7.4"] +anthropic = ["mobilerun[anthropic]==0.8.0"] +deepseek = ["mobilerun[deepseek]==0.8.0"] +langfuse = ["mobilerun[langfuse]==0.8.0"] +typesafe = ["mobilerun[typesafe]==0.8.0"] +dev = ["mobilerun[dev]==0.8.0"] [project.scripts] droidrun = "droidrun.cli_shim:main" diff --git a/mobilerun/__init__.py b/mobilerun/__init__.py index 90910650..081262c5 100644 --- a/mobilerun/__init__.py +++ b/mobilerun/__init__.py @@ -3,26 +3,24 @@ """ import logging +from importlib import import_module from importlib.metadata import version +from typing import TYPE_CHECKING + +from mobilerun.agent.utils.errors import HtmlErrorPageFilter +from mobilerun.log_handlers import CLILogHandler __version__ = version("mobilerun") # Attach a default CLILogHandler so that every consumer (CLI, SDK, # tools-only) gets visible output without explicit setup. CLI flows can # replace this with their own handler via ``configure_logging()``. -from mobilerun.log_handlers import CLILogHandler _logger = logging.getLogger("mobilerun") _logger.addHandler(CLILogHandler()) _logger.setLevel(logging.INFO) _logger.propagate = False -# Import main classes for easier access -from mobilerun.agent import ResultEvent # noqa: E402 -from mobilerun.agent.droid import MobileAgent, MobileAgentState # noqa: E402 -from mobilerun.agent.utils.decision_loader import load_decision_model # noqa: E402 -from mobilerun.agent.utils.errors import HtmlErrorPageFilter # noqa: E402 -from mobilerun.agent.utils.llm_picker import load_llm # noqa: E402 # llama-index's retry warnings include provider error bodies verbatim. for _retry_logger in ( @@ -31,35 +29,64 @@ ): logging.getLogger(_retry_logger).addFilter(HtmlErrorPageFilter()) -# Import configuration classes -from mobilerun.config_manager import ( # noqa: E402 - # Agent configs - AgentConfig, - AppCardConfig, - CredentialsConfig, - DecisionConfig, - DecisionProfile, - DelegationConfig, - # Feature configs - DeviceConfig, - ExecutorConfig, - FastAgentConfig, - LLMProfile, - LoggingConfig, - ManagerConfig, - MobileConfig, - TelemetryConfig, - ToolsConfig, - TracingConfig, -) - -# Import macro functionality -from mobilerun.macro import ( # noqa: E402 - MacroPlayer, - replay_macro_file, - replay_macro_folder, -) -from mobilerun.tools import AndroidDriver, DeviceDriver, RecordingDriver # noqa: E402 +if TYPE_CHECKING: + from mobilerun.agent import ResultEvent + from mobilerun.agent.droid import MobileAgent, MobileAgentState + from mobilerun.agent.utils.decision_loader import load_decision_model + from mobilerun.agent.utils.llm_picker import load_llm + from mobilerun.config_manager import ( + AgentConfig, + AppCardConfig, + CredentialsConfig, + DecisionConfig, + DecisionProfile, + DelegationConfig, + DeviceConfig, + ExecutorConfig, + FastAgentConfig, + LLMProfile, + LoggingConfig, + ManagerConfig, + MobileConfig, + TelemetryConfig, + ToolsConfig, + TracingConfig, + ) + from mobilerun.macro import MacroPlayer, replay_macro_file, replay_macro_folder + from mobilerun.tools import AndroidDriver, DeviceDriver, RecordingDriver + +_EXPORTS = { + "ResultEvent": ("mobilerun.agent", "ResultEvent"), + "MobileAgent": ("mobilerun.agent.droid", "MobileAgent"), + "MobileAgentState": ("mobilerun.agent.droid", "MobileAgentState"), + "load_decision_model": ( + "mobilerun.agent.utils.decision_loader", + "load_decision_model", + ), + "load_llm": ("mobilerun.agent.utils.llm_picker", "load_llm"), + "AgentConfig": ("mobilerun.config_manager", "AgentConfig"), + "AppCardConfig": ("mobilerun.config_manager", "AppCardConfig"), + "CredentialsConfig": ("mobilerun.config_manager", "CredentialsConfig"), + "DecisionConfig": ("mobilerun.config_manager", "DecisionConfig"), + "DecisionProfile": ("mobilerun.config_manager", "DecisionProfile"), + "DelegationConfig": ("mobilerun.config_manager", "DelegationConfig"), + "DeviceConfig": ("mobilerun.config_manager", "DeviceConfig"), + "ExecutorConfig": ("mobilerun.config_manager", "ExecutorConfig"), + "FastAgentConfig": ("mobilerun.config_manager", "FastAgentConfig"), + "LLMProfile": ("mobilerun.config_manager", "LLMProfile"), + "LoggingConfig": ("mobilerun.config_manager", "LoggingConfig"), + "ManagerConfig": ("mobilerun.config_manager", "ManagerConfig"), + "MobileConfig": ("mobilerun.config_manager", "MobileConfig"), + "TelemetryConfig": ("mobilerun.config_manager", "TelemetryConfig"), + "ToolsConfig": ("mobilerun.config_manager", "ToolsConfig"), + "TracingConfig": ("mobilerun.config_manager", "TracingConfig"), + "MacroPlayer": ("mobilerun.macro", "MacroPlayer"), + "replay_macro_file": ("mobilerun.macro", "replay_macro_file"), + "replay_macro_folder": ("mobilerun.macro", "replay_macro_folder"), + "AndroidDriver": ("mobilerun.tools", "AndroidDriver"), + "DeviceDriver": ("mobilerun.tools", "DeviceDriver"), + "RecordingDriver": ("mobilerun.tools", "RecordingDriver"), +} # Make main components available at package level __all__ = [ @@ -115,5 +142,16 @@ def __getattr__(name): DeprecationWarning, stacklevel=2, ) - return globals()[new_name] + return getattr(import_module(__name__), new_name) + if name in _EXPORTS: + module, attribute = _EXPORTS[name] + value = getattr(import_module(module), attribute) + globals()[name] = value + return value + if name in {"agent", "tools", "macro", "config_manager"}: + return import_module(f"{__name__}.{name}") raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return sorted(set(globals()) | set(__all__)) diff --git a/mobilerun/agent/__init__.py b/mobilerun/agent/__init__.py index 80fc0f33..cf0dc2bd 100644 --- a/mobilerun/agent/__init__.py +++ b/mobilerun/agent/__init__.py @@ -1,3 +1,26 @@ -from mobilerun.agent.droid.events import ResultEvent +from importlib import import_module +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mobilerun.agent.droid.events import ResultEvent + +_EXPORTS = { + "ResultEvent": ("mobilerun.agent.droid.events", "ResultEvent"), +} __all__ = ["ResultEvent"] + + +def __getattr__(name): + if name in _EXPORTS: + module, attribute = _EXPORTS[name] + value = getattr(import_module(module), attribute) + globals()[name] = value + return value + if name in {"droid", "utils"}: + return import_module(f"{__name__}.{name}") + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return sorted(set(globals()) | set(__all__)) diff --git a/mobilerun/agent/droid/__init__.py b/mobilerun/agent/droid/__init__.py index 2beab72a..0cfea8a3 100644 --- a/mobilerun/agent/droid/__init__.py +++ b/mobilerun/agent/droid/__init__.py @@ -1,21 +1,26 @@ -""" -Mobilerun Agent Module. +"""Mobilerun Agent Module. This module provides a ReAct agent for automating Android devices using reasoning and acting. """ -from mobilerun.agent.droid.droid_agent import MobileAgent -from mobilerun.agent.droid.state import MobileAgentState +from importlib import import_module +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mobilerun.agent.droid.droid_agent import MobileAgent + from mobilerun.agent.droid.state import MobileAgentState -# Legacy aliases for backward compatibility -_LEGACY_ALIASES = { - "DroidAgent": MobileAgent, - "DroidAgentState": MobileAgentState, +_EXPORTS = { + "MobileAgent": ("mobilerun.agent.droid.droid_agent", "MobileAgent"), + "MobileAgentState": ("mobilerun.agent.droid.state", "MobileAgentState"), } +__all__ = ["MobileAgent", "MobileAgentState", "DroidAgent", "DroidAgentState"] + def __getattr__(name): - if name in _LEGACY_ALIASES: + aliases = {"DroidAgent": "MobileAgent", "DroidAgentState": "MobileAgentState"} + if name in aliases: import warnings warnings.warn( @@ -23,8 +28,14 @@ def __getattr__(name): DeprecationWarning, stacklevel=2, ) - return _LEGACY_ALIASES[name] + return getattr(import_module(__name__), aliases[name]) + if name in _EXPORTS: + module, attribute = _EXPORTS[name] + value = getattr(import_module(module), attribute) + globals()[name] = value + return value raise AttributeError(f"module {__name__!r} has no attribute {name!r}") -__all__ = ["MobileAgent", "MobileAgentState", "DroidAgent", "DroidAgentState"] +def __dir__(): + return sorted(set(globals()) | set(__all__)) diff --git a/mobilerun/agent/droid/droid_agent.py b/mobilerun/agent/droid/droid_agent.py index 78441b53..64c05f5a 100644 --- a/mobilerun/agent/droid/droid_agent.py +++ b/mobilerun/agent/droid/droid_agent.py @@ -483,9 +483,7 @@ def __init__( ) self._stream_screenshots = ( - os.environ.get("MOBILERUN_STREAM_SCREENSHOTS") - or os.environ.get("DROIDRUN_STREAM_SCREENSHOTS") - or "" + os.environ.get("MOBILERUN_STREAM_SCREENSHOTS") or "" ).lower() in ("1", "true") self.timeout = timeout diff --git a/mobilerun/agent/fast_agent/fast_agent.py b/mobilerun/agent/fast_agent/fast_agent.py index 5ae3a704..9859657d 100644 --- a/mobilerun/agent/fast_agent/fast_agent.py +++ b/mobilerun/agent/fast_agent/fast_agent.py @@ -187,9 +187,7 @@ def __init__( self.state_provider = state_provider self.save_trajectory = save_trajectory self._stream_screenshots = ( - os.environ.get("MOBILERUN_STREAM_SCREENSHOTS") - or os.environ.get("DROIDRUN_STREAM_SCREENSHOTS") - or "" + os.environ.get("MOBILERUN_STREAM_SCREENSHOTS") or "" ).lower() in ("1", "true") self.shared_state = shared_state self.output_model = output_model diff --git a/mobilerun/agent/manager/manager_agent.py b/mobilerun/agent/manager/manager_agent.py index 34755452..5a148c06 100644 --- a/mobilerun/agent/manager/manager_agent.py +++ b/mobilerun/agent/manager/manager_agent.py @@ -107,9 +107,7 @@ def __init__( self.state_provider = state_provider self.save_trajectory = save_trajectory self._stream_screenshots = ( - os.environ.get("MOBILERUN_STREAM_SCREENSHOTS") - or os.environ.get("DROIDRUN_STREAM_SCREENSHOTS") - or "" + os.environ.get("MOBILERUN_STREAM_SCREENSHOTS") or "" ).lower() in ("1", "true") self.shared_state = shared_state self.registry = registry diff --git a/mobilerun/agent/utils/__init__.py b/mobilerun/agent/utils/__init__.py index 55edf33d..6965e4b7 100644 --- a/mobilerun/agent/utils/__init__.py +++ b/mobilerun/agent/utils/__init__.py @@ -1,16 +1,31 @@ -""" -Utility modules for Mobilerun agents. -""" - -from .chat_utils import ( - filter_empty_messages, - has_content, - limit_history, - to_chat_messages, -) -from .prompt_resolver import PromptResolver -from .signatures import build_tool_registry -from .trajectory import Trajectory +"""Utility modules for Mobilerun agents.""" + +from importlib import import_module +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mobilerun.agent.utils.chat_utils import ( + filter_empty_messages, + has_content, + limit_history, + to_chat_messages, + ) + from mobilerun.agent.utils.prompt_resolver import PromptResolver + from mobilerun.agent.utils.signatures import build_tool_registry + from mobilerun.agent.utils.trajectory import Trajectory + +_EXPORTS = { + "filter_empty_messages": ( + "mobilerun.agent.utils.chat_utils", + "filter_empty_messages", + ), + "has_content": ("mobilerun.agent.utils.chat_utils", "has_content"), + "limit_history": ("mobilerun.agent.utils.chat_utils", "limit_history"), + "to_chat_messages": ("mobilerun.agent.utils.chat_utils", "to_chat_messages"), + "PromptResolver": ("mobilerun.agent.utils.prompt_resolver", "PromptResolver"), + "build_tool_registry": ("mobilerun.agent.utils.signatures", "build_tool_registry"), + "Trajectory": ("mobilerun.agent.utils.trajectory", "Trajectory"), +} __all__ = [ # Chat utilities @@ -25,3 +40,16 @@ # Trajectory "Trajectory", ] + + +def __getattr__(name): + if name in _EXPORTS: + module, attribute = _EXPORTS[name] + value = getattr(import_module(module), attribute) + globals()[name] = value + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return sorted(set(globals()) | set(__all__)) diff --git a/mobilerun/agent/utils/android_device.py b/mobilerun/agent/utils/android_device.py index 2901d68b..d32db87d 100644 --- a/mobilerun/agent/utils/android_device.py +++ b/mobilerun/agent/utils/android_device.py @@ -22,7 +22,7 @@ def create_android_portal_http_driver( if not token: raise ValueError( "Android Portal HTTP needs the Portal auth token. Copy it from the " - "Portal app and set MOBILERUN_DEVICE_TOKEN or device.auth_token." + "Portal app and set MOBILERUN_ANDROID_PORTAL_TOKEN or device.auth_token." ) return AndroidPortalHttpDriver(url, token) diff --git a/mobilerun/agent/utils/llm_picker.py b/mobilerun/agent/utils/llm_picker.py index 0d00f66c..34a14b17 100644 --- a/mobilerun/agent/utils/llm_picker.py +++ b/mobilerun/agent/utils/llm_picker.py @@ -147,7 +147,7 @@ def _validate_gemini_oauth_model(model: object) -> None: raise ValueError( f"Model '{model_id}' is a Gemini Developer API id and is not valid " f"for the Antigravity OAuth catalog. Re-run " - f"`mobilerun configure gemini` and pick one of: {supported}." + f"`mobilerun auth login gemini` and pick one of: {supported}." ) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index 2655654e..2e29fa03 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -530,9 +530,7 @@ def login( # Headless environments: skip local server, use hosted callback page use_headless = _is_headless_environment() or ( - os.environ.get("MOBILERUN_OAUTH_MANUAL") - or os.environ.get("DROIDRUN_OAUTH_MANUAL") - or "" + os.environ.get("MOBILERUN_OAUTH_MANUAL") or "" ).lower() in ("1", "true", "yes") if use_headless: return self.login_headless( diff --git a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py index 65f62fcb..c9a2ddbf 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -257,7 +257,7 @@ def __init__( raise ValueError( f"Gemini OAuth model '{selected_model}' is retired. " f"Use '{GEMINI_OAUTH_DEFAULT_MODEL}' or re-run " - "`mobilerun configure gemini` to choose an available model." + "`mobilerun auth login gemini` to choose an available model." ) super().__init__( @@ -604,9 +604,7 @@ def login( # Headless environments: use authcode redirect flow (no local server) use_authcode = _is_headless_environment() or ( - os.environ.get("MOBILERUN_OAUTH_MANUAL") - or os.environ.get("DROIDRUN_OAUTH_MANUAL") - or "" + os.environ.get("MOBILERUN_OAUTH_MANUAL") or "" ).lower() in ("1", "true", "yes") if use_authcode: return self.login_headless( diff --git a/mobilerun/agent/utils/oauth/grok_oauth_llm.py b/mobilerun/agent/utils/oauth/grok_oauth_llm.py index 1392c2ec..ea34a4da 100644 --- a/mobilerun/agent/utils/oauth/grok_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/grok_oauth_llm.py @@ -598,7 +598,7 @@ def exchange_authorization_code( def _refresh(self, credentials: GrokOAuthCredentials) -> GrokOAuthCredentials: if not credentials.refresh_token: raise ValueError( - "No XAI OAuth refresh token is available. Run `mobilerun configure xai`." + "No XAI OAuth refresh token is available. Run `mobilerun auth login xai`." ) payload = self._post_token( { @@ -633,7 +633,7 @@ def get_valid_credentials( ) if credentials is None: raise ValueError( - "No XAI OAuth credentials found. Run `mobilerun configure xai`." + "No XAI OAuth credentials found. Run `mobilerun auth login xai`." ) another_writer_refreshed = ( diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index b7d193b8..c662d8fa 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -712,7 +712,7 @@ def __init__( raise ValueError( f"Model '{resolved_model}' is not supported with OpenAI OAuth " f"ChatGPT-account credentials. Use '{OPENAI_OAUTH_DEFAULT_MODEL}' " - "or re-run `mobilerun configure openai`." + "or re-run `mobilerun auth login openai`." ) seed_api_key = oauth_access_token or kwargs.pop("api_key", None) or "oauth" @@ -776,9 +776,7 @@ def login( # Headless environments: use device code flow (no local server needed) use_device_code = _is_headless_environment() or ( - os.environ.get("MOBILERUN_OAUTH_MANUAL") - or os.environ.get("DROIDRUN_OAUTH_MANUAL") - or "" + os.environ.get("MOBILERUN_OAUTH_MANUAL") or "" ).lower() in ("1", "true", "yes") if use_device_code: return self._login_device_code( diff --git a/mobilerun/cli/__init__.py b/mobilerun/cli/__init__.py index 78b2a428..024cb75f 100644 --- a/mobilerun/cli/__init__.py +++ b/mobilerun/cli/__init__.py @@ -1,9 +1,11 @@ -""" -Mobilerun CLI Module. +"""Mobilerun command-line interface.""" -This module provides command-line interfaces for interacting with Android devices. -""" +__all__ = ["cli"] -from mobilerun.cli.main import cli -__all__ = ["cli"] +def __getattr__(name): + if name == "cli": + from mobilerun.cli.main import cli + + return cli + raise AttributeError(name) diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py new file mode 100644 index 00000000..19c63f96 --- /dev/null +++ b/mobilerun/cli/auth.py @@ -0,0 +1,429 @@ +"""Sign-in commands using the existing credential flows.""" + +from __future__ import annotations + +import math +import os +import sys +from pathlib import Path + +import click +from rich.console import Console + +from mobilerun.cli import deviceauth +from mobilerun.cli.options import ( + DEFAULT_AUTH_URL, + Group, + auth_url_option, + emit, + examples, + input_allowed, + json_option, + no_input_option, + read_secret, + verbose_option, +) +from mobilerun.cli.qr import print_qr +from mobilerun.cli.selection import CLOUD_CREDENTIAL_LABELS, resolve_cloud_credential +from mobilerun.config_manager.auth_profile_store import AuthProfileStore +from mobilerun.config_manager.credential_paths import ( + ANTHROPIC_OAUTH_CREDENTIAL_PATH, + GEMINI_OAUTH_CREDENTIAL_PATH, + GROK_OAUTH_CREDENTIAL_PATH, + OPENAI_OAUTH_CREDENTIAL_PATH, +) + +console = Console(stderr=True) + + +def _validate_oauth_timeout( + _ctx: click.Context, + _param: click.Parameter, + value: float | None, +) -> float: + if value is not None and (not math.isfinite(value) or value <= 0): + raise click.BadParameter("must be a finite number greater than zero") + return value + + +def _print_oauth_login_success(provider_label: str, credential_path: str) -> None: + console.print(f"[green]{provider_label} login succeeded.[/]") + console.print( + f"[blue]Credentials saved to:[/] {Path(credential_path).expanduser()}" + ) + + +def _run_openai_oauth_login(credential_path: str, model: str | None, **kwargs) -> None: + from mobilerun.cli.oauth_actions import run_openai_oauth_login + + run_openai_oauth_login(credential_path=credential_path, model=model, **kwargs) + _print_oauth_login_success("OpenAI", credential_path) + + +def _run_gemini_oauth_login(credential_path: str, model: str | None, **kwargs) -> None: + from mobilerun.cli.oauth_actions import run_gemini_oauth_login + + run_gemini_oauth_login(credential_path=credential_path, model=model, **kwargs) + _print_oauth_login_success("Gemini", credential_path) + + +def _run_anthropic_oauth_login(credential_path: str, **kwargs) -> None: + """Run the full Anthropic OAuth flow inline and save the token.""" + from mobilerun.cli.oauth_actions import run_anthropic_setup_token_oauth + + console.print("[blue]Starting Anthropic login...[/]") + run_anthropic_setup_token_oauth( + credential_path=credential_path, + **kwargs, + ) + _print_oauth_login_success("Anthropic", credential_path) + + +def _run_grok_oauth_login( + credential_path: str, model: str | None = None, **kwargs +) -> None: + from mobilerun.cli.oauth_actions import run_grok_oauth_login + + run_grok_oauth_login( + credential_path=credential_path, + model=model, + **kwargs, + ) + _print_oauth_login_success("XAI", credential_path) + + +DEFAULT_DEVICE_CLIENT_ID = "mobilerun-cli" + + +def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool, browser: bool): + """Authenticate via OAuth 2.0 device authorization and save the session token.""" + import webbrowser + + try: + code = deviceauth.request_code(auth_url, client_id) + except Exception as e: + raise click.ClickException( + f"request device code: {e}\nCheck --auth-url or MOBILERUN_AUTH_URL: {auth_url}" + ) from e + + link = code.verification_uri_complete + terminal = sys.stderr.isatty() + styled = terminal and "NO_COLOR" not in os.environ + if link: + if terminal and not no_qr: + print_qr(link) + display = f"\033]8;;{link}\033\\{link}\033]8;;\033\\" if styled else link + click.echo(display, err=True, color=styled) + click.echo(f"Visit: {code.verification_uri}", err=True) + click.echo(f"Code: {code.user_code}", err=True) + if browser and link: + try: + webbrowser.open(link) + except Exception: + pass + waiting = f"Waiting for authorization (expires in {code.expires_in}s)..." + console.print(f"[dim]{waiting}[/]" if styled else waiting) + + try: + token = deviceauth.poll_token(auth_url, client_id, code) + except deviceauth.DeviceAuthError as e: + raise click.ClickException(f"authorization failed: {e}") from e + + path = deviceauth.save_token(token, auth_url) + email = None + try: + info = deviceauth.fetch_session(auth_url, token) + if info: + email = info.get("user", {}).get("email") + except Exception: + pass + who = f" as {email}" if email else "" + console.print(f"[green]Logged in{who}.[/] Token saved to {path}") + + +SERVICES = { + "openai": (OPENAI_OAUTH_CREDENTIAL_PATH, "openai_oauth"), + "anthropic": (ANTHROPIC_OAUTH_CREDENTIAL_PATH, "anthropic_oauth"), + "gemini": (GEMINI_OAUTH_CREDENTIAL_PATH, "gemini_oauth_code_assist"), + "xai": (GROK_OAUTH_CREDENTIAL_PATH, "xai_oauth"), +} +LOGIN_OPTIONS = { + "mobilerun": {"auth_url", "client_id", "no_qr", "browser", "no_browser"}, + "openai": { + "credential_path", + "timeout", + "no_browser", + "callback_host", + "callback_port", + "callback_path", + "model", + }, + "anthropic": { + "credential_path", + "timeout", + "no_browser", + "callback_host", + "callback_port", + "callback_path", + "token", + }, + "gemini": { + "credential_path", + "timeout", + "no_browser", + "callback_host", + "callback_port", + "callback_path", + "model", + }, + "xai": {"credential_path", "timeout", "no_browser", "device_code", "model"}, +} + +service_argument = click.argument( + "service", + type=click.Choice(["mobilerun", *SERVICES]), + default="mobilerun", + metavar="[SERVICE]", +) + + +@click.group(cls=Group, epilog=examples("mobilerun auth status")) +@verbose_option +def auth_cli(verbose): + """Sign in, sign out, and inspect saved credentials.""" + + +@auth_cli.command( + epilog=examples( + "mobilerun auth login", + "mobilerun auth login --browser", + "mobilerun auth login openai --no-browser", + ), +) +@service_argument +@verbose_option +@auth_url_option +@click.option("-m", "--model", help="LLM model (openai, gemini, xai).") +@click.option( + "--client-id", default=None, help="Mobilerun device authorization client id." +) +@click.option( + "--credential-path", + type=click.Path(dir_okay=False), + help="Provider credential file (openai, anthropic, gemini, xai).", +) +@click.option( + "--timeout", + type=float, + default=300.0, + callback=_validate_oauth_timeout, + help="Provider login timeout in seconds (openai, anthropic, gemini, xai).", +) +@click.option( + "--no-browser", + is_flag=True, + help="Do not open a provider login browser (openai, anthropic, gemini, xai); " + "unnecessary for mobilerun, which does not open one by default.", +) +@click.option( + "--browser", is_flag=True, help="Open the Mobilerun sign-in link in the browser." +) +@click.option( + "--no-qr", is_flag=True, help="Do not display the Mobilerun sign-in QR code." +) +@click.option( + "--callback-host", help="OAuth callback host (openai, anthropic, gemini)." +) +@click.option( + "--callback-port", + type=click.IntRange(0, 65535), + help="OAuth callback port (openai, anthropic, gemini).", +) +@click.option( + "--callback-path", help="OAuth callback path (openai, anthropic, gemini)." +) +@click.option("--device-code", is_flag=True, help="Use xAI device-code login.") +@no_input_option +@click.option( + "--token", + callback=read_secret, + help="Anthropic setup-token; prefer '-' for stdin, " + "because flag values end up in shell history.", +) +@click.pass_context +def login( + ctx, + service, + verbose, + auth_url, + model, + client_id, + credential_path, + timeout, + no_browser, + browser, + no_qr, + callback_host, + callback_port, + callback_path, + device_code, + token, + no_input, +): + """Log in to a service; SERVICE defaults to mobilerun.""" + from mobilerun.cli.oauth_actions import ( + login_requires_input, + save_anthropic_setup_token, + ) + + for name in set().union(*LOGIN_OPTIONS.values()): + if ( + ctx.get_parameter_source(name) == click.core.ParameterSource.COMMANDLINE + and name not in LOGIN_OPTIONS[service] + ): + raise click.UsageError( + f"--{name.replace('_', '-')} is not supported for {service}" + ) + if not input_allowed(no_input) and login_requires_input(service): + if service == "anthropic" and not token: + raise click.UsageError( + "Missing option '--token'; pass --token - to read a setup-token from stdin." + ) + if service == "gemini": + raise click.UsageError( + "Gemini login needs a code; omit --no-input and run " + "`mobilerun auth login gemini` in a terminal." + ) + if service == "mobilerun": + _login_mobilerun( + auth_url, client_id or DEFAULT_DEVICE_CLIENT_ID, no_qr, browser + ) + credential = resolve_cloud_credential() + if credential.source == "env": + click.echo( + f"Warning: {credential.env_var} is set and takes precedence over this sign-in; " + "unset it to use the sign-in.", + err=True, + ) + return + kwargs = { + "credential_path": credential_path or str(SERVICES[service][0]), + "timeout": timeout, + "open_browser": not no_browser, + } + if service in {"openai", "gemini", "anthropic"}: + for name, value in ( + ("callback_host", callback_host), + ("callback_port", callback_port), + ("callback_path", callback_path), + ): + if value is not None: + kwargs[name] = value + if service == "anthropic": + if token: + save_anthropic_setup_token(kwargs["credential_path"], token) + _print_oauth_login_success("Anthropic", kwargs["credential_path"]) + else: + _run_anthropic_oauth_login(**kwargs) + else: + kwargs["model"] = model + if service == "xai": + kwargs["device_code"] = device_code + { + "openai": _run_openai_oauth_login, + "gemini": _run_gemini_oauth_login, + "xai": _run_grok_oauth_login, + }[service](**kwargs) + + +@auth_cli.command(epilog=examples("mobilerun auth logout")) +@service_argument +@verbose_option +def logout(service, verbose): + """Remove SERVICE's stored credential, preserving other services.""" + from mobilerun.cli.configure_wizard import ( + _OAUTH_CREDENTIAL_SLOTS, + _oauth_credentials_present, + ) + + if service == "mobilerun": + removed = deviceauth.clear_credentials() + else: + path, variant = SERVICES[service] + removed = _oauth_credentials_present(str(path), variant) + if path.exists(): + AuthProfileStore(path).update_profile( + lambda profile: profile.pop(_OAUTH_CREDENTIAL_SLOTS[variant], None) + ) + console.print( + f"Logged out of {service}." if removed else f"Not logged in to {service}." + ) + if service == "mobilerun": + credential = resolve_cloud_credential() + if credential.source == "env": + console.print( + f"{credential.env_var} stays active; unset it to stop using the env key." + ) + + +@auth_cli.command(epilog=examples("mobilerun auth status -j")) +@verbose_option +@json_option +def status(verbose, json_output): + """Show saved sign-in status without exposing tokens.""" + from mobilerun.cli.configure_wizard import _oauth_credentials_present + + credentials = deviceauth.load_credentials() or {} + token = credentials.get("access_token") + credential = resolve_cloud_credential() + rows = [ + { + "service": "mobilerun", + "signed_in": bool(token), + "credential_path": str(deviceauth.credential_path()), + "email": None, + "cloud_credential": credential.source, + "env_overrides_login": credential.source == "env" + and credential.saved_sign_in, + } + ] + failed = False + if token: + try: + session = deviceauth.fetch_session( + credentials.get("auth_url") or DEFAULT_AUTH_URL, token + ) + if session is None: + failed = True + else: + rows[0]["email"] = session.get("user", {}).get("email") + except Exception: + failed = True + for service, (path, variant) in SERVICES.items(): + rows.append( + { + "service": service, + "signed_in": _oauth_credentials_present(str(path), variant), + "credential_path": str(path), + } + ) + if failed: + raise click.ClickException( + "Mobilerun session check failed; run `mobilerun auth login` again." + ) + if json_output: + emit(rows, json_output=True) + else: + cloud_note = ( + "; cloud commands have no credential; run `mobilerun auth login` " + "or set MOBILERUN_API_KEY" + if credential.source is None + else f"; cloud commands use {CLOUD_CREDENTIAL_LABELS[credential.source]}" + ) + if credential.source == "env" and credential.saved_sign_in: + cloud_note += " (overrides saved sign-in)" + for row in rows: + label = "signed in" if row["signed_in"] else "not signed in" + email = f" ({row['email']})" if row.get("email") else "" + cloud = cloud_note if row["service"] == "mobilerun" else "" + emit(f"{row['service']}: {label}{email} {row['credential_path']}{cloud}") diff --git a/mobilerun/cli/completion.py b/mobilerun/cli/completion.py new file mode 100644 index 00000000..b9e3c501 --- /dev/null +++ b/mobilerun/cli/completion.py @@ -0,0 +1,16 @@ +"""Print Click's shell completion scripts without installing them.""" + +import click +from click.shell_completion import get_completion_class + +from mobilerun.cli.options import Command, emit, examples, verbose_option + + +@click.command(cls=Command, epilog=examples("mobilerun completion bash")) +@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"])) +@verbose_option +def completion(shell, verbose): + """Print the completion script for bash, zsh, or fish.""" + root = click.get_current_context().find_root().command + complete = get_completion_class(shell)(root, {}, "mobilerun", "_MOBILERUN_COMPLETE") + emit(complete.source()) diff --git a/mobilerun/cli/configure.py b/mobilerun/cli/configure.py new file mode 100644 index 00000000..d8d134d8 --- /dev/null +++ b/mobilerun/cli/configure.py @@ -0,0 +1,133 @@ +"""Interactive configuration command.""" + +from __future__ import annotations + +import click +from rich.console import Console + +from mobilerun.cli.auth import ( + LOGIN_OPTIONS, + _run_anthropic_oauth_login, + _run_gemini_oauth_login, + _run_grok_oauth_login, + _run_openai_oauth_login, +) +from mobilerun.cli.options import ( + Command, + config_option, + examples, + input_allowed, + llm_url_option, + model_option, + no_input_option, + read_secret, + verbose_option, +) +from mobilerun.cli.providers import PROVIDERS, configure_provider_option + + +class ConfigureCommand(Command): + def parse_args(self, ctx, args): + try: + return super().parse_args(ctx, args) + except click.UsageError as exc: + for service in LOGIN_OPTIONS: + if exc.message == f"Got unexpected extra argument ({service})": + exc.message += f". Use 'mobilerun auth login {service}' to sign in." + break + raise + + +@click.command( + cls=ConfigureCommand, + epilog=examples( + "mobilerun configure", + "mobilerun configure -p openai -m MODEL", + ), +) +@configure_provider_option +@model_option +@llm_url_option +@verbose_option +@config_option +@no_input_option +@click.option( + "-k", + "--api-key", + callback=read_secret, + help="Provider API key; prefer '-' for stdin or the provider's environment variable, " + "because flag values end up in shell history.", +) +@click.option( + "--auth-mode", + type=click.Choice(["api_key", "oauth", "none"]), + help="Authentication mode for the provider.", +) +def configure( + provider, model, llm_url, verbose, config_path, api_key, auth_mode, no_input +): + """Configure provider, authentication and model with the wizard.""" + from mobilerun.cli.configure_wizard import ( + ConfigureWizardCallbacks, + run_configure_wizard, + ) + + spec = PROVIDERS.get(provider) + if spec: + auth_mode = auth_mode or spec.auth_mode + if not input_allowed(no_input): + _validate_non_interactive(provider, model, api_key, llm_url, auth_mode) + run_configure_wizard( + Console(stderr=True), + ConfigureWizardCallbacks( + run_openai_oauth_login=_run_openai_oauth_login, + run_anthropic_oauth_login=_run_anthropic_oauth_login, + run_gemini_oauth_login=_run_gemini_oauth_login, + run_grok_oauth_login=_run_grok_oauth_login, + ), + provider=spec.family if spec else None, + auth_mode=auth_mode, + model=model, + api_key=api_key, + base_url=llm_url, + config_path=config_path, + ) + + +def _validate_non_interactive(provider, model, api_key, base_url, auth_mode): + """Validate before loading or saving config, or entering the wizard.""" + from mobilerun.agent.providers.registry import ( + VARIANT_ENV_KEY_SLOT, + resolve_provider_variant, + ) + from mobilerun.cli.configure_wizard import _oauth_credentials_present + from mobilerun.config_manager.env_keys import resolve_env_key + + if not provider: + raise click.UsageError("Missing option '-p / --provider'; specify a provider.") + if not model: + raise click.UsageError("Missing option '-m / --model'; specify a model.") + spec = PROVIDERS[provider] + try: + variant = resolve_provider_variant(spec.family, auth_mode) + except ValueError as exc: + raise click.UsageError(f"{exc}; check --auth-mode for -p {provider}.") from None + slot = VARIANT_ENV_KEY_SLOT.get(variant.id) + if variant.requires_api_key and not ( + api_key or (slot and resolve_env_key(slot, "auto")) + ): + raise click.UsageError( + "Missing option '-k / --api-key'; pass -k - to read stdin, " + "or set the provider's environment variable." + ) + if variant.requires_base_url and not (base_url or variant.base_url): + raise click.UsageError( + "Missing option '-u / --llm-url'; specify the provider endpoint." + ) + if variant.auth_mode == "oauth" and not _oauth_credentials_present( + variant.credential_path, variant.id + ): + raise click.UsageError( + f"Saved OAuth credentials required; run `mobilerun auth login {spec.family}` " + "in a terminal first, or choose -p PROVIDER with API-key authentication." + ) diff --git a/mobilerun/cli/configure_wizard.py b/mobilerun/cli/configure_wizard.py index 598c5f98..594956f8 100644 --- a/mobilerun/cli/configure_wizard.py +++ b/mobilerun/cli/configure_wizard.py @@ -911,8 +911,9 @@ def run_configure_wizard( model: str | None, api_key: str | None, base_url: str | None, + config_path: str | None = None, ) -> None: - config = ConfigLoader.load() + config = ConfigLoader.load(config_path) if config_path else ConfigLoader.load() _print_configure_intro(console) families = family_choices() @@ -956,7 +957,7 @@ def run_configure_wizard( base_url=base_url, ) if provider_configured: - ConfigLoader.save(config) + ConfigLoader.save(config, path=config_path) _print_configure_summary( console, family_id=state.family_id or "", @@ -1039,7 +1040,7 @@ def choose_delegate_llm(): _configure_advanced_settings(console, config) elif action == "finish": - ConfigLoader.save(config) + ConfigLoader.save(config, path=config_path) if provider_configured and state.family_id: _print_configure_summary( console, diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index ba674374..3d5ef2d8 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -1,457 +1,313 @@ -"""Direct device action CLI commands. +"""Synchronous device actions through mobilerun-core.""" -Provides ``mobilerun device `` subcommands that bypass the LLM agent -and talk directly to the device driver. -""" +from __future__ import annotations import asyncio +import base64 import json -import logging -import os -import re -import sys import tempfile -from functools import wraps -from typing import Optional +from collections.abc import Mapping +from contextlib import contextmanager import click -from async_adbutils import adb -from mobilerun_core_local.driver.android import AndroidDriver -from mobilerun_core_local.driver.android.portal import ensure_portal_ready -from mobilerun_core_local.driver.ios import ( - IOSPortalHttpDriver, - create_ios_driver, - discover_ios_device, - validate_ios_portal_url, -) -from rich.console import Console -from mobilerun.agent.utils.android_device import ( - AndroidDeviceSelectionError, - create_android_portal_http_driver, - is_portal_http_url, - resolve_android_serial, +from mobilerun.cli.options import ( + Group, + _ui_cell, + device_options, + emit, + emit_rows, + examples, + json_option, + verbose_option, ) -from mobilerun.agent.utils.portal_setup import portal_version_kwargs -from mobilerun.config_manager import ConfigLoader -from mobilerun.log_handlers import configure_logging -from mobilerun.tools.filters import ConciseFilter -from mobilerun.tools.formatters import IndexedFormatter -from mobilerun.tools.ui.ios_provider import IOSStateProvider -from mobilerun.tools.ui.provider import AndroidStateProvider - -console = Console() - -DEFAULT_CLOUD_BASE_URL = "https://api.mobilerun.ai/v1" -CLOUD_API_KEY_ENV = "MOBILERUN_API_KEY" -LEGACY_CLOUD_API_KEY_ENV = "MOBILERUN_CLOUD_API_KEY" - -# Cloud device ids are UUIDs (RFC 4122); ADB serials never are. Used to -# auto-route `-d ` to the cloud driver when the user didn't pass `--cloud`. -_CLOUD_DEVICE_ID_RE = re.compile( - r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", - re.IGNORECASE, +from mobilerun.cli.selection import ( + core_client, + local_adb_errors, + portal_token, + select_device, ) +UI_FLAGS = ( + "clickable", + "checkable", + "checked", + "editable", + "focused", + "scrollable", + "selected", +) -def _looks_like_cloud_device_id(value: Optional[str]) -> bool: - return bool(value and _CLOUD_DEVICE_ID_RE.match(value)) - - -def resolve_cloud_api_key() -> Optional[str]: - """Resolve the Mobilerun cloud API key for ``--cloud`` commands. - - Order: the ``MOBILERUN_API_KEY`` env var, then the older - ``MOBILERUN_CLOUD_API_KEY`` name (still accepted), then the credential persisted - by ``mobilerun login`` (``/credentials/mobilerun-cloud.json``). - An empty env var counts as unset. - """ - env_key = os.environ.get(CLOUD_API_KEY_ENV) or os.environ.get( - LEGACY_CLOUD_API_KEY_ENV - ) - if env_key: - return env_key - try: - from mobilerun.config_manager.credential_paths import OAUTH_CREDENTIAL_DIR - - cred_path = OAUTH_CREDENTIAL_DIR / "mobilerun-cloud.json" - if cred_path.exists(): - data = json.loads(cred_path.read_text()) - key = data.get("api_key") or data.get("access_token") - if key: - return key - except Exception: - pass - return None - - -def coro(f): - @wraps(f) - def wrapper(*args, **kwargs): - return asyncio.run(f(*args, **kwargs)) - - return wrapper - - -def device_options(f): - """Common device options for all action commands.""" - f = click.option( - "--device", - "-d", - help="Device serial/IP or Portal HTTP URL (local), or cloud device UUID " - "(auto-detected when authenticated)", - default=None, - )(f) - f = click.option( - "--config", "-c", "config_path", help="Path to config file", default=None - )(f) - f = click.option("--tcp/--no-tcp", default=None, help="Use TCP communication")(f) - f = click.option("--ios", is_flag=True, default=False, help="Target iOS device")(f) - f = click.option( - "--cloud", - is_flag=True, - default=False, - help="Target a Mobilerun cloud device instead of a local one", - )(f) - f = click.option( - "--device-id", - "device_id", - default=None, - help="Cloud device id (-d also works; auto-detected when value is a UUID)", - )(f) - f = click.option( - "--base-url", - "base_url", - default=None, - help=f"Cloud API base URL (default {DEFAULT_CLOUD_BASE_URL})", - )(f) - return f - - -async def _create_driver( - device: Optional[str], - config_path: Optional[str], - tcp: Optional[bool], - ios: bool, - cloud: bool = False, - device_id: Optional[str] = None, - base_url: Optional[str] = None, -): - """Create and connect a device driver based on CLI options.""" - # Auto-route to cloud when -d/--device-id is a UUID and a credential exists. - # Mirrors `mobilerun devices` (which includes cloud when authenticated), so - # users don't have to remember `--cloud` on every per-action subcommand. - if not cloud and not ios: - candidate = device_id or device - if _looks_like_cloud_device_id(candidate) and resolve_cloud_api_key(): - cloud = True - - if cloud: - if ios: - raise click.ClickException("--cloud and --ios are mutually exclusive.") - cloud_device_id = device_id or device - if not cloud_device_id: - raise click.ClickException( - "Cloud device id required: pass -d or --device-id ." - ) - api_key = resolve_cloud_api_key() - if not api_key: - raise click.ClickException( - f"No cloud API key found. Set {CLOUD_API_KEY_ENV} or run " - "`mobilerun login`." - ) - from mobilerun_core_local.driver.cloud import CloudDriver - - driver = CloudDriver( - device_id=cloud_device_id, - api_key=api_key, - base_url=base_url or DEFAULT_CLOUD_BASE_URL, - ) - await driver.connect() - return driver, False - - config = ConfigLoader.load(config_path) - - if device is not None: - config.device.serial = device - if tcp is not None: - config.device.use_tcp = tcp - if ios: - config.device.platform = "ios" - is_ios = config.device.platform.lower() == "ios" +@contextmanager +def device_driver(params=None, *, selection=None): + """Resolve Click's device options once and yield the connected core Device.""" + from mobilerun.agent.utils.android_device import is_portal_http_url - if is_ios: - if config.device.serial: - url = validate_ios_portal_url(config.device.serial) - else: - url = await discover_ios_device() - driver = await create_ios_driver(url, token=config.device.resolve_auth_token()) - await driver.connect() - return driver, True - - serial = config.device.serial - if is_portal_http_url(serial): - try: - driver = create_android_portal_http_driver( - serial, config.device.resolve_auth_token() + params = params if params is not None else click.get_current_context().params + if selection is None: + selection = asyncio.run( + select_device( + device=params.get("device"), + config_path=params.get("config_path"), + platform=params.get("platform"), + target=params.get("target", "local"), ) - except ValueError as e: - raise click.ClickException(str(e)) from None - await driver.connect() - return driver, False - - if serial is None: - try: - serial = await resolve_android_serial() - except AndroidDeviceSelectionError as e: - raise click.ClickException(str(e)) from None - click.echo(f"Using device: {serial}", err=True) - - if config.device.auto_setup and config.device.portal_mode != "disabled": - device_obj = await adb.device(serial=serial) - await ensure_portal_ready( - device_obj, debug=False, **portal_version_kwargs(ensure_portal_ready) - ) - - driver = AndroidDriver( - serial=serial, - use_tcp=config.device.use_tcp, - portal_mode=config.device.portal_mode, - ) - await driver.connect() - return driver, False - - -def _log_progress_to_stderr() -> None: - """Show INFO progress (e.g. a slow UI-state fetch) on stderr. - - Without this, Python's last-resort handler drops everything below - WARNING, so a slow device looks like a silent hang. stdout stays - reserved for the command's output. - """ - if logging.getLogger("mobilerun").handlers: - return - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter("%(message)s")) - configure_logging(debug=False, handler=handler) - - -async def _teardown_android(driver): - """Disable Mobilerun keyboard after direct command execution.""" - if isinstance(driver, AndroidDriver) and driver.device: - from mobilerun_core_local.driver.android.portal import ( - PORTAL_PACKAGE_NAME, - portal_ime_id, ) - + token = portal_token(selection) + client = core_client(params["api_url"], cloud=selection.backend == "cloud") + with local_adb_errors(selection.backend == "local-android-adb"): try: - ime = portal_ime_id(PORTAL_PACKAGE_NAME) - await driver.device.shell(f"ime disable {ime}") - except Exception: - pass - - -# --------------------------------------------------------------------------- -# Click group -# --------------------------------------------------------------------------- + connected = client.connect( + selection.device, + backend=selection.backend, + platform=selection.platform, + url=selection.device if is_portal_http_url(selection.device) else None, + token=token, + ) + try: + yield connected + finally: + connected.close() + finally: + client.close() -@click.group() -def device_cli(): - """Direct device actions (screenshot, tap, swipe, etc.).""" - pass +def probe_device(params, *, selection=None): + with device_driver(params, selection=selection) as device: + device.ui() -# --------------------------------------------------------------------------- -# Commands -# --------------------------------------------------------------------------- +@click.group( + cls=Group, + epilog=examples( + "mobilerun device list", + "mobilerun device pair", + "mobilerun device ui -d DEVICE", + ), +) +@verbose_option +def device_cli(verbose): + """Discover, pair, set up, inspect, and control devices.""" -@device_cli.command() +@device_cli.command(epilog=examples("mobilerun device screenshot -d emulator-5554")) +@json_option @device_options -@coro -async def screenshot(device, config_path, tcp, ios, cloud, device_id, base_url): +def screenshot(json_output, **params): """Take a screenshot and print the saved file path to stdout.""" - driver, _ = await _create_driver( - device, config_path, tcp, ios, cloud, device_id, base_url - ) - try: - png_bytes = await driver.screenshot() - fd, path = tempfile.mkstemp(prefix="mobilerun_", suffix=".png") - try: - os.write(fd, png_bytes) - finally: - os.close(fd) - click.echo(path) - finally: - await _teardown_android(driver) + with device_driver(params) as device: + image = device.screenshot() + # Core returns a file for cloud screenshots, base64 PNG for local ones. + if image.startswith(("/", "./")): + path = image + else: + with tempfile.NamedTemporaryFile( + prefix="mobilerun_", suffix=".png", delete=False + ) as file: + file.write(base64.b64decode(image)) + path = file.name + emit({"path": path} if json_output else path, json_output=json_output) + + +def _field(node, *keys): + for key in keys: + value = node.get(key) + if value and (not isinstance(value, str) or value.strip()): + return value + return "" + + +def _ltrb(bounds): + if not isinstance(bounds, Mapping) or not bounds: + return "" + if all(key in bounds for key in ("x", "y", "width", "height")): + values = ( + bounds["x"], + bounds["y"], + bounds["x"] + bounds["width"], + bounds["y"] + bounds["height"], + ) + else: + values = tuple( + bounds.get(key, "") for key in ("left", "top", "right", "bottom") + ) + return ",".join(_ui_cell(value) for value in values) + + +def ui_lines(state, all_elements=False): + """Render core's depth-first node list as six tab-separated columns.""" + for index, node in enumerate(state.nodes): + flags = [ + flag + for flag in UI_FLAGS + if _field(node, flag, "is" + flag.capitalize(), "is_" + flag) + ] + if node.get("disabled") or any( + node.get(key) is False for key in ("enabled", "isEnabled", "is_enabled") + ): + flags.append("disabled") + text = _field(node, "text", "contentDescription", "content_description") + resource = _field(node, "resourceId", "resource_id", "accessibilityIdentifier") + if not (all_elements or text or resource or flags): + continue + class_name = _field(node, "className", "class_name") + bounds = _field( + node, "bounds", "boundsInScreen", "bounds_in_screen", "rect", "frame" + ) + yield "\t".join( + ( + str(index), + _ui_cell(class_name.rsplit(".", 1)[-1]), + _ui_cell(text), + _ui_cell(resource), + _ltrb(bounds), + ",".join(flags), + ) + ) -@device_cli.command() +@device_cli.command( + epilog=examples( + "mobilerun device ui -d DEVICE", + "mobilerun device ui -d DEVICE -j", + ), +) +@json_option +@click.option( + "--all", "all_elements", is_flag=True, help="Include empty layout containers." +) @device_options -@coro -async def ui(device, config_path, tcp, ios, cloud, device_id, base_url): - """Print the UI accessibility tree with element bounds for targeting.""" - _log_progress_to_stderr() - driver, is_ios = await _create_driver( - device, config_path, tcp, ios, cloud, device_id, base_url - ) - try: - if is_ios and not isinstance(driver, IOSPortalHttpDriver): - provider = IOSStateProvider(driver) - else: - provider = AndroidStateProvider( - driver, - tree_filter=ConciseFilter(), - tree_formatter=IndexedFormatter(), - ) - state = await provider.get_state() - click.echo(state.formatted_text) - if state.phone_state: - click.echo(f"\nPhone state: {state.phone_state}") - finally: - await _teardown_android(driver) +def ui(json_output, all_elements, **params): + """Print tab-separated UI elements, or the raw state with --json.""" + if json_output and all_elements: + raise click.UsageError("--all cannot be combined with --json") + with device_driver(params) as device: + if json_output: + emit(json.loads(device.ui_json()), json_output=True) + return + state = device.ui() + if ( + getattr(state, "dead_tree", False) + or getattr(state, "a11y_tree_available", True) is False + ): + raise click.ClickException("The device UI tree is unavailable.") + for line in ui_lines(state, all_elements): + emit(line) + phone = state.get("phone_state") or {} + context = state.get("device_context") or {} + screen = context.get("screen_bounds") or context.get("display_metrics") or {} + app = phone.get("currentApp") or phone.get("packageName") or "unknown" + width = screen.get("width", screen.get("widthPixels", "?")) + height = screen.get("height", screen.get("heightPixels", "?")) + click.echo( + f"App: {_ui_cell(app)}; screen: {_ui_cell(width)}x{_ui_cell(height)}", + err=True, + ) -@device_cli.command() +@device_cli.command(dash_hint=True, epilog=examples("mobilerun device tap 100 200")) @click.argument("x", type=int) @click.argument("y", type=int) @device_options -@coro -async def tap(x, y, device, config_path, tcp, ios, cloud, device_id, base_url): +def tap(x, y, **params): """Tap at screen coordinates.""" - driver, _ = await _create_driver( - device, config_path, tcp, ios, cloud, device_id, base_url - ) - try: - await driver.tap(x, y) - click.echo(f"Tapped ({x}, {y})") - finally: - await _teardown_android(driver) + with device_driver(params) as device: + device.tap(x, y, humanize=False) + click.echo(f"Tapped ({x}, {y})", err=True) -@device_cli.command("swipe") +@device_cli.command( + "swipe", dash_hint=True, epilog=examples("mobilerun device swipe 100 500 100 100") +) @click.argument("x1", type=int) @click.argument("y1", type=int) @click.argument("x2", type=int) @click.argument("y2", type=int) @click.option( - "--duration", type=float, default=1.0, show_default=True, help="Duration in seconds" + "--duration", + type=float, + default=1.0, + show_default=True, + help="Duration in seconds.", ) @device_options -@coro -async def swipe_cmd( - x1, y1, x2, y2, duration, device, config_path, tcp, ios, cloud, device_id, base_url -): +def swipe_cmd(x1, y1, x2, y2, duration, **params): """Swipe from (x1, y1) to (x2, y2).""" - driver, _ = await _create_driver( - device, config_path, tcp, ios, cloud, device_id, base_url - ) - try: - await driver.swipe(x1, y1, x2, y2, duration_ms=int(duration * 1000)) - click.echo(f"Swiped ({x1}, {y1}) -> ({x2}, {y2})") - finally: - await _teardown_android(driver) + with device_driver(params) as device: + device.swipe(x1, y1, x2, y2, ms=int(duration * 1000), humanize=False) + click.echo(f"Swiped ({x1}, {y1}) -> ({x2}, {y2})", err=True) -@device_cli.command("long-press") +@device_cli.command( + "long-press", dash_hint=True, epilog=examples("mobilerun device long-press 100 200") +) @click.argument("x", type=int) @click.argument("y", type=int) @device_options -@coro -async def long_press(x, y, device, config_path, tcp, ios, cloud, device_id, base_url): +def long_press(x, y, **params): """Long press at screen coordinates.""" - if ios: - raise click.ClickException("long-press is not supported on iOS") - driver, _ = await _create_driver( - device, config_path, tcp, ios, cloud, device_id, base_url - ) - try: - await driver.swipe(x, y, x, y, 1000) - click.echo(f"Long pressed ({x}, {y})") - finally: - await _teardown_android(driver) - - -@device_cli.command("type") + with device_driver(params) as device: + device.swipe(x, y, x, y, ms=1000, humanize=False) + click.echo(f"Long pressed ({x}, {y})", err=True) + + +@device_cli.command( + "type", dash_hint=True, epilog=examples('mobilerun device type "Hello"') +) @click.argument("text") -@click.option("--clear", is_flag=True, default=False, help="Clear field before typing") +@click.option("--clear", is_flag=True, help="Clear field before typing.") @device_options -@coro -async def type_text( - text, clear, device, config_path, tcp, ios, cloud, device_id, base_url -): - """Type text into the currently focused field. Use 'tap' first to focus.""" - driver, _ = await _create_driver( - device, config_path, tcp, ios, cloud, device_id, base_url - ) - try: - success = await driver.input_text(text, clear) - if success: - click.echo(f"Typed: {text}") - else: - raise click.ClickException("Failed to type text") - finally: - await _teardown_android(driver) +def type_text(text, clear, **params): + """Type text into the focused field.""" + with device_driver(params) as device: + device.type(text, clear=clear, humanize=False) + click.echo("Text typed.", err=True) -@device_cli.command() +@device_cli.command(epilog=examples("mobilerun device press home")) @click.argument( "button", type=click.Choice(["back", "home", "enter"], case_sensitive=False) ) @device_options -@coro -async def press(button, device, config_path, tcp, ios, cloud, device_id, base_url): +def press(button, **params): """Press a system button.""" - driver, _ = await _create_driver( - device, config_path, tcp, ios, cloud, device_id, base_url - ) - try: - await driver.press_button(button) - click.echo(f"Pressed {button}") - finally: - await _teardown_android(driver) - - -@device_cli.command() -@click.option("--system/--no-system", default=False, help="Include system apps") + with device_driver(params) as device: + device.key(button) + click.echo(f"Pressed {button}", err=True) + + +@device_cli.command(epilog=examples("mobilerun device apps -j")) +@click.option("--system", is_flag=True, help="Include system apps.") +@json_option @device_options -@coro -async def apps(system, device, config_path, tcp, ios, cloud, device_id, base_url): +def apps(system, json_output, **params): """List installed apps.""" - driver, _ = await _create_driver( - device, config_path, tcp, ios, cloud, device_id, base_url - ) - try: - app_list = await driver.get_apps(include_system=system) - for app in app_list: - label = app.get("label") or "" - package = ( - app.get("package") - or app.get("package_name") - or app.get("packageName") - or "" + with device_driver(params) as device: + result = device.list_apps(include_system_apps=system) + if json_output: + emit(result, json_output=True) + else: + emit_rows( + ("APP_ID", "LABEL", "VERSION", "VERSION_CODE", "SYSTEM"), + ( + ( + _field(app, "app_id", "package_name", "bundle_id", "package"), + app.get("label", ""), + app.get("version_name", ""), + app.get("version_code", ""), + str(app.get("is_system_app", False)).lower(), + ) + for app in result + ), ) - if label and label != package: - click.echo(f"{package} ({label})") - else: - click.echo(package) - finally: - await _teardown_android(driver) -@device_cli.command() +@device_cli.command(epilog=examples("mobilerun device start com.android.settings")) @click.argument("package") @device_options -@coro -async def start(package, device, config_path, tcp, ios, cloud, device_id, base_url): +def start(package, **params): """Launch an app by package name.""" - driver, _ = await _create_driver( - device, config_path, tcp, ios, cloud, device_id, base_url - ) - try: - result = await driver.start_app(package) - click.echo(result) - finally: - await _teardown_android(driver) + with device_driver(params) as device: + device.start_app(package) + click.echo(f"Started {package}", err=True) diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py new file mode 100644 index 00000000..67aa2cc4 --- /dev/null +++ b/mobilerun/cli/device_lifecycle.py @@ -0,0 +1,193 @@ +"""Device discovery and Android lifecycle commands.""" + +from __future__ import annotations + +import asyncio + +import click +from async_adbutils import adb + +from mobilerun.cli.device_commands import device_cli, probe_device +from mobilerun.cli.device_pair import adb_connect_succeeded +from mobilerun.cli.options import ( + NO_CLOUD_DEVICES_HINT, + NO_LOCAL_DEVICES_HINT, + Command, + api_url_option, + config_option, + convert_api_error, + coro, + device_option, + emit, + emit_rows, + examples, + json_option, + platform_option, + redact_secrets, + target_option, + verbose_option, +) +from mobilerun.cli.selection import core_client, local_adb_errors, select_device + + +@device_cli.command( + "list", + epilog=examples( + "mobilerun device list", + "mobilerun device list --all -j", + ), +) +@target_option +@config_option +@api_url_option +@verbose_option +@json_option +@click.option( + "--all", "all_devices", is_flag=True, help="List local and cloud devices." +) +def list_devices(target, config_path, api_url, verbose, json_output, all_devices): + """List devices in the selected target.""" + from mobilerun.config_manager import ConfigLoader + + ConfigLoader.load(config_path) + rows, errors = [], [] + scopes = ["local", "cloud"] if all_devices else [target] + for scope in scopes: + try: + rows.extend(_list_target(api_url, scope)) + except Exception as exc: + if not all_devices: + raise + converted = convert_api_error(exc) + message = converted[1]["error"] if converted else str(exc) + message = " ".join( + redact_secrets(message, click.get_current_context()).split() + ) + errors.append({"target": scope, "error": message}) + click.echo(f"Warning: {scope}: {' '.join(message.split())}", err=True) + if json_output: + emit( + {"devices": rows, "errors": errors} if all_devices else rows, + json_output=True, + ) + elif rows: + emit_rows( + ("DEVICE_ID", "NAME", "STATE", "TYPE"), + ( + (row["device_id"], row["name"] or "-", row["state"], row["type"]) + for row in rows + ), + ) + elif len(errors) < len(scopes): + hint = ( + NO_CLOUD_DEVICES_HINT + if target == "cloud" and not all_devices + else NO_LOCAL_DEVICES_HINT + ) + click.echo("No devices found. " + hint, err=True) + if len(errors) == len(scopes): + click.get_current_context().exit(1) + + +def _list_target(api_url, scope): + client = core_client(api_url, cloud=scope == "cloud") + devices = [] + try: + with local_adb_errors(scope == "local"): + devices = client.list_devices(filters={"state": None}, scope=scope) + if scope == "local" and not devices: + # Core discovery treats ADB failures as an empty list. Let the + # library try startup so an unavailable server is reported. + asyncio.run(asyncio.wait_for(adb.list(), 3)) + rows = [] + for device in devices: + rows.append( + { + "device_id": device.device_id, + "name": device.name, + "state": "connected" if scope == "local" else device.state, + "type": ( + device.capabilities["backend"] + if scope == "local" + else device.device_type + ), + } + ) + return rows + finally: + for resource in [*devices, client]: + resource.close() + + +@device_cli.command(epilog=examples("mobilerun device connect 192.0.2.1:5555")) +@click.argument("serial", metavar="HOST:PORT") +@verbose_option +@coro +async def connect(serial, verbose): + """Connect to an Android device over TCP/IP.""" + with local_adb_errors(): + result = await adb.connect(serial) + if not adb_connect_succeeded(result): + raise click.ClickException(f"Failed to connect to {serial}: {result}") + click.echo(f"Successfully connected to {serial}", err=True) + + +@device_cli.command(epilog=examples("mobilerun device disconnect 192.0.2.1:5555")) +@click.argument("serial", metavar="HOST:PORT") +@verbose_option +@coro +async def disconnect(serial, verbose): + """Disconnect an Android TCP/IP device.""" + with local_adb_errors(): + success = await adb.disconnect(serial, raise_error=True) + click.echo( + ( + f"Successfully disconnected from {serial}" + if success + else f"Device {serial} was not connected" + ), + err=True, + ) + + +@device_cli.command( + removed_flags={**Command.removed_flags, "--path": "--apk"}, + epilog=examples("mobilerun device setup -d emulator-5554"), +) +@device_option +@config_option +@verbose_option +@click.option( + "--apk", type=click.Path(exists=True, dir_okay=False), help="Portal APK file." +) +@click.option("--portal-version", help="Specific Portal version.") +@click.option("--latest", is_flag=True, help="Install the latest Portal.") +@coro +async def setup(device, config_path, verbose, apk, portal_version, latest): + """Install and enable the Portal on a local Android device.""" + from mobilerun.cli.setup import _setup_portal + + if sum(bool(x) for x in (apk, portal_version, latest)) > 1: + raise click.UsageError("Choose one of --apk, --portal-version or --latest") + selection = await select_device(device, config_path) + if selection.backend != "local-android-adb": + raise click.ClickException("Portal setup requires a local Android ADB device") + if not await _setup_portal( + apk, selection.device, bool(verbose), latest, portal_version + ): + raise click.ClickException( + "Portal setup failed; run `mobilerun doctor` for diagnostics" + ) + + +@device_cli.command(epilog=examples("mobilerun device ping -d emulator-5554")) +@device_option +@target_option +@config_option +@api_url_option +@verbose_option +@platform_option +def ping(**params): + """Connect to a device and read its UI state once.""" + probe_device(params) + click.echo("Device is reachable and serving the UI tree.", err=True) diff --git a/mobilerun/cli/device_pair.py b/mobilerun/cli/device_pair.py new file mode 100644 index 00000000..591fd000 --- /dev/null +++ b/mobilerun/cli/device_pair.py @@ -0,0 +1,236 @@ +"""Pair and connect a local Android device through adb wireless debugging.""" + +import re +import secrets +import string +import subprocess +import sys +import time +from ipaddress import ip_address +from typing import NamedTuple + +import click + +from mobilerun.cli.device_commands import device_cli +from mobilerun.cli.options import emit, examples, verbose_option +from mobilerun.cli.qr import print_qr +from mobilerun.cli.selection import local_adb_errors + + +class MdnsService(NamedTuple): + name: str + service_type: str + address: str + + +def parse_mdns_services(output: str) -> list[MdnsService]: + """Read adb's tabular services, ignoring its header and unrelated lines.""" + services = [] + for line in output.splitlines(): + fields = line.split() + if len(fields) != 3: + continue + name, service_type, address = fields + service_type = service_type.rstrip(".") + host, _, port = address.rpartition(":") + if not port.isdigit() or not 0 < int(port) < 65536: + continue + if ":" in host and not (host.startswith("[") and host.endswith("]")): + continue + try: + ip = ip_address(host.removeprefix("[").removesuffix("]")) + except ValueError: + continue + if not ip.is_link_local: + services.append(MdnsService(name, service_type, address)) + return sorted(services, key=lambda service: service.address.startswith("[")) + + +def parse_adb_devices(output: str) -> dict[str, str]: + """Read the serial and state columns, ignoring adb's header.""" + devices = {} + for line in output.splitlines(): + fields = line.split() + if len(fields) == 2: + devices[fields[0]] = fields[1] + return devices + + +def _run_adb( + *args: str, input: str | None = None, timeout: float = 10 +) -> subprocess.CompletedProcess[str]: + """Keep adb execution in one replaceable boundary; never log its input.""" + return subprocess.run( + ["adb", *args], + input=input, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + + +def _adb_failure( + result: subprocess.CompletedProcess[str], fallback: str +) -> click.ClickException: + return click.ClickException( + result.stderr.strip() or result.stdout.strip() or fallback + ) + + +def adb_connect_succeeded(output: str) -> bool: + """Recognize adb's connect replies consistently for both commands.""" + return any( + line.lower().startswith(("connected to ", "already connected to ")) + for line in output.splitlines() + ) + + +def _adb(*args: str, deadline: float, input: str | None = None): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise click.ClickException("Timed out waiting for wireless debugging.") + try: + result = _run_adb(*args, input=input, timeout=min(10, remaining)) + except subprocess.TimeoutExpired: + raise click.ClickException( + "Timed out waiting for adb wireless debugging." + ) from None + # adb diagnostics must not echo the pairing password, even on failure. + if input: + secret = input.rstrip("\r\n") + if secret: + result.stdout = result.stdout.replace(secret, "[redacted]") + result.stderr = result.stderr.replace(secret, "[redacted]") + if result.returncode: + raise _adb_failure(result, "adb command failed.") + return result + + +def _wait_for_service(service_type: str, deadline: float, *, name: str) -> MdnsService: + while True: + result = _adb("mdns", "services", deadline=deadline) + for service in parse_mdns_services(result.stdout): + if service.service_type == service_type and service.name == name: + return service + time.sleep(1) + + +def _guid_transport(devices: dict[str, str], guid: str) -> str | None: + return next( + ( + serial + for serial, state in devices.items() + if serial.startswith(guid) and state == "device" + ), + None, + ) + + +def _connect_addresses(guid: str, deadline: float) -> list[str]: + result = _adb("mdns", "services", deadline=deadline) + return [ + service.address + for service in parse_mdns_services(result.stdout) + if service.name == guid and service.service_type == "_adb-tls-connect._tcp" + ] + + +def _wait_for_transport(guid: str, deadline: float) -> str: + fallback_at = time.monotonic() + 15 + fallback = None + while True: + devices = parse_adb_devices(_adb("devices", deadline=deadline).stdout) + serial = _guid_transport(devices, guid) + if serial: + addresses = _connect_addresses(guid, deadline) + # Recheck at completion so a late auto-connect wins over TCP. + devices = parse_adb_devices(_adb("devices", deadline=deadline).stdout) + serial = _guid_transport(devices, guid) + if serial: + for address in dict.fromkeys([*addresses, fallback]): + if address and address in devices: + _adb("disconnect", address, deadline=deadline) + click.echo( + f"Disconnected duplicate transport {address}; keeping {serial}.", + err=True, + ) + return serial + elif ( + fallback + and devices.get(fallback) == "device" + and not any(serial.startswith(guid) for serial in devices) + ): + return fallback + elif fallback is None and time.monotonic() >= fallback_at: + address = next( + ( + address + for address in _connect_addresses(guid, deadline) + if not address.startswith("[") + ), + None, + ) + if address: + # The transport may have appeared during mDNS discovery. + devices = parse_adb_devices(_adb("devices", deadline=deadline).stdout) + if not any(serial.startswith(guid) for serial in devices): + result = _adb("connect", address, deadline=deadline) + if not adb_connect_succeeded(result.stdout): + raise _adb_failure(result, "adb connection failed.") + fallback = address + time.sleep(1) + + +@device_cli.command( + epilog=examples( + "mobilerun device pair", + "mobilerun device pair --timeout 180", + ), +) +@verbose_option +@click.option( + "--timeout", + type=click.IntRange(min=1), + default=120, + show_default=True, + help="Maximum time in seconds to pair and connect.", +) +def pair(timeout: int, verbose: bool | None): + """Pair a local Android device by scanning a wireless debugging QR code.""" + if not sys.stderr.isatty(): + raise click.UsageError("This command needs a terminal.") + deadline = time.monotonic() + timeout + with local_adb_errors(): + check = _adb("mdns", "check", deadline=deadline) + if "mdns daemon version" not in check.stdout.lower(): + raise click.ClickException( + "adb mDNS is unavailable; enable adb mDNS discovery." + ) + + name = "mobilerun-" + "".join( + secrets.choice(string.ascii_lowercase + string.digits) for _ in range(6) + ) + password = "".join( + secrets.choice(string.ascii_letters + string.digits) for _ in range(10) + ) + click.echo( + "On the device, open Developer options → Wireless debugging → " + '"Pair device with QR code"; keep both devices on the same Wi-Fi and scan below.', + err=True, + ) + if not print_qr(f"WIFI:T:ADB;S:{name};P:{password};;"): + raise click.ClickException("Could not display the pairing QR code.") + pairing = _wait_for_service("_adb-tls-pairing._tcp", deadline, name=name) + result = _adb("pair", pairing.address, input=password + "\n", deadline=deadline) + if "successfully paired to" not in result.stdout.lower(): + raise _adb_failure(result, "adb pairing failed.") + identity = re.search(r"\[guid=([^\]\s]+)\]", result.stdout) + if not identity: + raise _adb_failure(result, "adb pairing did not return a device GUID.") + serial = _wait_for_transport(identity.group(1), deadline) + emit(serial) + click.echo( + f"Paired and connected. Run `mobilerun device setup -d {serial}`.", + err=True, + ) diff --git a/mobilerun/cli/doctor.py b/mobilerun/cli/doctor.py index 398759d2..b8eeaf0b 100644 --- a/mobilerun/cli/doctor.py +++ b/mobilerun/cli/doctor.py @@ -3,11 +3,11 @@ import asyncio import json import re -import shutil from dataclasses import dataclass from enum import Enum from typing import Any +import click import httpx import requests from async_adbutils import AdbDevice, adb @@ -26,7 +26,7 @@ from mobilerun import __version__ -console = Console() +console = Console(stderr=True) PORTAL_REMOTE_PORT = 8080 @@ -131,45 +131,29 @@ async def check_sdk_version(debug: bool) -> CheckResult: return CheckResult("SDK Version", Status.PASS, f"{current} (up to date)") -async def check_config(debug: bool) -> tuple[CheckResult, Any]: +async def check_config( + debug: bool, config_path: str | None = None +) -> tuple[CheckResult, Any]: """Check config file exists and loads.""" from mobilerun.config_manager import ConfigLoader from mobilerun.config_manager.loader import OutdatedConfigError - config_path = ConfigLoader.get_user_config_path() - - if not config_path.exists(): - # Will be created on first load - try: - config = ConfigLoader.load() - return ( - CheckResult( - "Config", - Status.PASS, - f"{config_path} (created)", - ), - config, - ) - except Exception as e: - return ( - CheckResult( - "Config", - Status.FAIL, - "failed to create default config", - detail=str(e), - ), - None, - ) - try: - config = ConfigLoader.load() - return CheckResult("Config", Status.PASS, str(config_path)), config + path = ConfigLoader.resolve_path(config_path) + existed = path.exists() + config = ConfigLoader.load(config_path) + return ( + CheckResult( + "Config", Status.PASS, str(path) + ("" if existed else " (created)") + ), + config, + ) except OutdatedConfigError: return ( CheckResult( "Config", Status.FAIL, - f"{config_path} (outdated, missing _version)", + f"{config_path or ConfigLoader.get_user_config_path()} (outdated, missing _version)", detail="See: mobilerun config_example.yaml", ), None, @@ -188,30 +172,22 @@ async def check_config(debug: bool) -> tuple[CheckResult, Any]: async def check_adb(debug: bool) -> CheckResult: """Check ADB is available and list devices.""" - adb_path = shutil.which("adb") - if not adb_path: - return CheckResult( - "ADB", - Status.FAIL, - "adb not found in PATH", - detail="Install Android SDK Platform Tools", - ) + from mobilerun.cli.selection import local_adb_errors try: - devices = await adb.list() + with local_adb_errors(): + devices = await adb.list() count = len(devices) return CheckResult( "ADB", Status.PASS, f"found, {count} device(s)", - detail=adb_path, ) except Exception as e: return CheckResult( "ADB", Status.FAIL, - f"adb found but server error: {e}", - detail=adb_path, + e.message if isinstance(e, click.ClickException) else str(e), ) @@ -382,7 +358,7 @@ async def check_portal_version( "Portal Version", Status.WARN, msg, - detail="pip install --upgrade mobilerun, then mobilerun setup", + detail="pip install --upgrade mobilerun, then mobilerun device setup", ), installed, expected, @@ -674,68 +650,116 @@ async def fix_enable_accessibility(device: AdbDevice, debug: bool) -> bool: async def run_doctor( device_serial: str | None = None, debug: bool = False, -) -> None: + config_path: str | None = None, + *, + target="local", + platform=None, + api_url=None, + render_results=True, +) -> list[CheckResult]: """Run all doctor checks and auto-fix where possible.""" debug = debug or False - console.print("\n[bold]Mobilerun Doctor[/]\n") + if render_results: + console.print("\n[bold]Mobilerun Doctor[/]\n") + + def report(result, debug): + if render_results: + _print_result(result, debug) + + def summary(results): + if render_results: + _print_summary(results) results: list[CheckResult] = [] # 1. SDK Version r = await check_sdk_version(debug) - _print_result(r, debug) + report(r, debug) results.append(r) # 2. Config - r_config, config = await check_config(debug) - _print_result(r_config, debug) + r_config, config = await check_config(debug, config_path) + report(r_config, debug) results.append(r_config) # Load serial from config if not provided if device_serial is None and config: device_serial = getattr(getattr(config, "device", None), "serial", None) + from mobilerun_core.detect import is_uuid + + from mobilerun.agent.utils.android_device import is_portal_http_url + from mobilerun.cli.device_commands import probe_device + from mobilerun.cli.options import DEFAULT_API_URL + from mobilerun.cli.selection import select_device + + selected_platform = platform or (config.device.platform if config else "android") + non_adb = ( + (target == "cloud" and not device_serial) + or selected_platform == "ios" + or is_portal_http_url(device_serial) + or bool(device_serial and is_uuid(device_serial)) + ) + # 3. ADB r = await check_adb(debug) - _print_result(r, debug) + report(r, debug) results.append(r) - if r.status == Status.FAIL: - _print_summary(results) - return + if r_config.status == Status.FAIL or (r.status == Status.FAIL and not non_adb): + summary(results) + return results + + if non_adb: + try: + selection = await select_device( + device_serial, + config_path, + platform=selected_platform, + target=target, + config=config, + ) + probe_device({"api_url": api_url or DEFAULT_API_URL}, selection=selection) + result = CheckResult("Device", Status.PASS, "UI tree is reachable") + except Exception as exc: + result = CheckResult("Device", Status.FAIL, str(exc)) + results.append(result) + report(result, debug) + summary(results) + return results # 5. Device r_device, device = await check_device(device_serial, debug) - _print_result(r_device, debug) + report(r_device, debug) results.append(r_device) if device is None: console.print("\n [dim]Skipping device-dependent checks (no device)[/]") - _print_summary(results) - return + summary(results) + return results # 6. Portal Installed r_portal, is_installed = await check_portal_installed(device, debug) - _print_result(r_portal, debug) + report(r_portal, debug) if not is_installed: # Auto-fix: run setup - from mobilerun.cli.main import _setup_portal + from mobilerun.cli.setup import _setup_portal await _setup_portal(path=None, device=device.serial, debug=debug) # Re-check r_portal, is_installed = await check_portal_installed(device, debug) if not is_installed: results.append(r_portal) - _print_summary(results) - return + summary(results) + return results results.append(r_portal) # 7. Portal Version r_version, installed_ver, _, _ = await check_portal_version(device, debug) - _print_result(r_version, debug) + report(r_version, debug) if r_version.status == Status.WARN and installed_ver: # Auto-fix: run setup to install compatible version console.print(" [blue]→ Updating portal...[/]") - from mobilerun.cli.main import _setup_portal + from mobilerun.cli.setup import _setup_portal await _setup_portal(path=None, device=device.serial, debug=debug, in_place=True) # Wait for portal service to initialize after install @@ -754,55 +778,56 @@ async def run_doctor( # 8. Accessibility r_a11y, a11y_enabled = await check_accessibility(device, debug) if not a11y_enabled: - _print_result(r_a11y, debug) + report(r_a11y, debug) # Auto-fix: enable accessibility success = await fix_enable_accessibility(device, debug) if success: r_a11y = CheckResult("Accessibility", Status.PASS, "enabled (auto-fixed)") - _print_result(r_a11y, debug) + report(r_a11y, debug) else: - _print_result(r_a11y, debug) + report(r_a11y, debug) results.append(r_a11y) # 9. Content Provider — connectivity, state, screenshot r = await check_content_provider(device, debug) - _print_result(r, debug) + report(r, debug) results.append(r) r_cp_state, cp_portal = await check_portal_state(device, False, debug) r_cp_state.name = "State (content)" - _print_result(r_cp_state, debug) + report(r_cp_state, debug) results.append(r_cp_state) if cp_portal and r_cp_state.status == Status.PASS: r = await check_screenshot(cp_portal, debug) r.name = "Screenshot (content)" - _print_result(r, debug) + report(r, debug) results.append(r) # 10. TCP — connectivity, state, screenshot r = await check_tcp(device, debug) - _print_result(r, debug) + report(r, debug) results.append(r) if r.status == Status.PASS: r_tcp_state, tcp_portal = await check_portal_state(device, True, debug) r_tcp_state.name = "State (tcp)" - _print_result(r_tcp_state, debug) + report(r_tcp_state, debug) results.append(r_tcp_state) if tcp_portal and r_tcp_state.status == Status.PASS: r = await check_screenshot(tcp_portal, debug) r.name = "Screenshot (tcp)" - _print_result(r, debug) + report(r, debug) results.append(r) # 12. Keyboard r = await check_keyboard(device, debug) - _print_result(r, debug) + report(r, debug) results.append(r) - _print_summary(results) + summary(results) + return results def _print_summary(results: list[CheckResult]) -> None: diff --git a/mobilerun/cli/doctor_command.py b/mobilerun/cli/doctor_command.py new file mode 100644 index 00000000..98565f3d --- /dev/null +++ b/mobilerun/cli/doctor_command.py @@ -0,0 +1,52 @@ +"""Click wiring for health checks.""" + +from __future__ import annotations + +import click + +from mobilerun.cli.options import ( + Command, + coro, + device_options, + emit, + examples, + json_option, + local_device_hint, +) + + +@click.command(cls=Command, epilog=examples("mobilerun doctor -j")) +@device_options +@json_option +@coro +async def doctor(device, config_path, platform, target, api_url, verbose, json_output): + """Check system and device health; fail if any check fails.""" + from mobilerun.cli.doctor import Status, run_doctor + + results = await run_doctor( + device, + bool(verbose), + config_path, + target=target, + platform=platform, + api_url=api_url, + render_results=False, + ) + failed = any(result.status == Status.FAIL for result in results) + checks = [ + { + "name": result.name, + "status": result.status.value, + "detail": local_device_hint( + "; ".join(filter(None, (result.message, result.detail))), target + ), + } + for result in results + ] + if json_output: + emit({"ok": not failed, "checks": checks}, json_output=True) + else: + for check in checks: + emit(f"{check['name']}: {check['status']}: {check['detail']}") + if failed: + click.get_current_context().exit(1) diff --git a/mobilerun/cli/env.py b/mobilerun/cli/env.py new file mode 100644 index 00000000..39567032 --- /dev/null +++ b/mobilerun/cli/env.py @@ -0,0 +1,148 @@ +"""Inspect resolved CLI environment values and their sources.""" + +from __future__ import annotations + +import os + +import click + +from mobilerun.cli import deviceauth +from mobilerun.cli.options import ( + DEFAULT_API_URL, + DEFAULT_AUTH_URL, + Command, + config_option, + emit, + examples, + json_option, + verbose_option, +) +from mobilerun.cli.selection import CLOUD_CREDENTIAL_LABELS, resolve_cloud_credential +from mobilerun.config_manager.env_keys import API_KEY_ENV_VARS, load_env_key_sources + +TRUTHY = {"1", "true", "yes", "on"} +BOOLEAN_VARIABLES = { + "MOBILERUN_DEBUG", + "MOBILERUN_STREAM_SCREENSHOTS", + "MOBILERUN_OAUTH_MANUAL", +} + + +def resolved_environment(config_path=None): + from mobilerun.config_manager import ConfigLoader, MobileConfig + from mobilerun.config_manager.config_manager import PORTAL_TOKEN_ENV_VARS + from mobilerun.telemetry.tracker import is_telemetry_enabled + + path = ConfigLoader.resolve_path(config_path) + exists = path.exists() + config = ConfigLoader.load(config_path) if exists else MobileConfig() + config_source = "config" if exists else "default" + ctx = click.get_current_context(silent=True) + path_source = ( + "flag" + if config_path + and ( + ctx is None + or ctx.get_parameter_source("config_path") + == click.core.ParameterSource.COMMANDLINE + ) + else "env" if os.environ.get("MOBILERUN_CONFIG") else "default" + ) + cloud_credential = resolve_cloud_credential() + credentials = deviceauth.load_credentials() or {} + values = { + "MOBILERUN_TARGET": ("local", "default", False), + "MOBILERUN_DEVICE": ( + config.device.serial, + config_source if config.device.serial else "not set", + False, + ), + "MOBILERUN_API_KEY": ( + cloud_credential.value, + cloud_credential.source, + True, + ), + "MOBILERUN_API_BASE_URL": (DEFAULT_API_URL, "default", False), + "MOBILERUN_AUTH_URL": ( + credentials.get("auth_url") or DEFAULT_AUTH_URL, + "credential file" if credentials.get("auth_url") else "default", + False, + ), + "MOBILERUN_CONFIG": (str(path), path_source, False), + "MOBILERUN_DEBUG": (config.logging.debug, config_source, False), + "MOBILERUN_TELEMETRY_ENABLED": (config.telemetry.enabled, config_source, False), + "MOBILERUN_STREAM_SCREENSHOTS": (False, "default", False), + "MOBILERUN_OAUTH_MANUAL": (False, "default", False), + "ANDROID_SERIAL": (None, "default", False), + "PHOENIX_URL": ("http://0.0.0.0:6006", "default", False), + "PHOENIX_PROJECT_NAME": ("", "default", False), + "NO_COLOR": (None, "default", False), + } + for platform, name in PORTAL_TOKEN_ENV_VARS.items(): + value = config.device.resolve_auth_token(platform) + source = ( + "env" if os.environ.get(name) else config_source if value else "not set" + ) + values[name] = (value, source, True) + sources = load_env_key_sources() + for slot, name in API_KEY_ENV_VARS.items(): + source = sources[slot] + values[name] = ( + source.saved, + "credential file" if source.saved else "default", + True, + ) + for name in ("OPENROUTER_API_KEY", "DEEPSEEK_API_KEY"): + values[name] = (None, "default", True) + not_from_env = {"MOBILERUN_API_KEY"} + if path_source == "flag": + not_from_env.add("MOBILERUN_CONFIG") + for name in values.keys() - not_from_env: + if os.environ.get(name): + values[name] = (os.environ[name], "env", values[name][2]) + if ctx and any( + parent.get_parameter_source("verbose") == click.core.ParameterSource.COMMANDLINE + for parent in (ctx, ctx.find_root()) + ): + values["MOBILERUN_DEBUG"] = (ctx.params.get("verbose"), "flag", False) + values["MOBILERUN_TELEMETRY_ENABLED"] = ( + is_telemetry_enabled(config_enabled=config.telemetry.enabled), + values["MOBILERUN_TELEMETRY_ENABLED"][1], + False, + ) + rows = [] + for name, (value, source, secret) in values.items(): + if name in BOOLEAN_VARIABLES and isinstance(value, str): + value = value.lower() in TRUTHY + if secret and not value and source is not None: + source = "not set" + if secret: + value = "set" if value else "not set" + rows.append({"variable": name, "value": value, "source": source}) + return rows + + +@click.command(cls=Command, epilog=examples("mobilerun env -j")) +@config_option +@verbose_option +@json_option +def env(config_path, verbose, json_output): + """Show resolved environment values (secrets are set/not set).""" + rows = resolved_environment(config_path) + if json_output: + emit(rows, json_output=True) + else: + for row in rows: + value = row["value"] + if isinstance(value, bool): + value = str(value).lower() + elif value is None: + value = "" + label = ( + CLOUD_CREDENTIAL_LABELS[row["source"]] + if row["variable"] == "MOBILERUN_API_KEY" + else row["source"] + ) + show_source = not (value == "not set" and label == "not set") + source = f" ({label})" if show_source else "" + emit(f"{row['variable']}={value}{source}") diff --git a/mobilerun/cli/main.py b/mobilerun/cli/main.py index 0c62fa92..bb246ae1 100644 --- a/mobilerun/cli/main.py +++ b/mobilerun/cli/main.py @@ -1,1683 +1,7 @@ -""" -Mobilerun CLI - Command line interface for controlling Android devices through LLM agents. -""" - -import asyncio -import importlib.metadata -import logging -import math -import os -import sys -import tomllib -import warnings -from contextlib import nullcontext -from dataclasses import replace -from functools import wraps -from pathlib import Path -from typing import Any - -import click -from async_adbutils import adb -from mobilerun_core_local.driver.android.portal import ( - DOWNLOAD_BASE, - PORTAL_PACKAGE_NAME, - check_portal_accessibility, - download_portal_apk, - download_versioned_portal_apk, - enable_portal_accessibility, - ping_portal, - ping_portal_content, - ping_portal_tcp, - portal_content_uri, - setup_portal, -) -from mobilerun_core_local.driver.ios import discover_ios_device, validate_ios_portal_url -from mobilerun_core_local.driver.visual_remote import VISUAL_REMOTE_CONNECTION -from rich.console import Console -from rich.panel import Panel -from rich.text import Text - -from mobilerun import MobileAgent, ResultEvent -from mobilerun.agent.external import list_agents -from mobilerun.agent.providers import list_provider_families -from mobilerun.agent.providers.setup_service import profile_for_provider -from mobilerun.agent.selectors import LlamaIndexLLMSelector -from mobilerun.agent.utils.android_device import ( - AndroidDeviceSelectionError, - create_android_portal_http_driver, - is_portal_http_url, - resolve_android_serial, -) -from mobilerun.agent.utils.errors import describe_error -from mobilerun.agent.utils.llm_picker import ( - SUPPORTED_PROVIDERS, - load_llm, - normalize_provider_name, -) -from mobilerun.agent.utils.oauth.openai_oauth_llm import ( - DEFAULT_OPENAI_OAUTH_CALLBACK_HOST, - DEFAULT_OPENAI_OAUTH_CALLBACK_PATH, - DEFAULT_OPENAI_OAUTH_CALLBACK_PORT, - DEFAULT_OPENAI_OAUTH_CREDENTIAL_PATH, -) -from mobilerun.agent.utils.portal_setup import portal_version_kwargs -from mobilerun.cli.configure_wizard import ( - ConfigureWizardCallbacks, - ensure_llm_delegation, - run_configure_wizard, -) -from mobilerun.cli.device_commands import ( - CLOUD_API_KEY_ENV, - DEFAULT_CLOUD_BASE_URL, - device_cli, - resolve_cloud_api_key, -) -from mobilerun.cli.event_handler import EventHandler -from mobilerun.cli.oauth_actions import ( - run_anthropic_setup_token_oauth, - run_gemini_oauth_login, - run_grok_oauth_login, - run_openai_oauth_login, - save_anthropic_setup_token, -) -from mobilerun.config_manager import ConfigLoader, MobileConfig -from mobilerun.config_manager.config_manager import DecisionProfile, LLMProfile -from mobilerun.config_manager.credential_paths import ( - ANTHROPIC_OAUTH_CREDENTIAL_PATH, - GEMINI_OAUTH_CREDENTIAL_PATH, - GROK_OAUTH_CREDENTIAL_PATH, -) -from mobilerun.log_handlers import CLILogHandler, configure_logging -from mobilerun.macro.cli import macro_cli -from mobilerun.telemetry import print_telemetry_message - -# Suppress all warnings -warnings.filterwarnings("ignore") -os.environ["TOKENIZERS_PARALLELISM"] = "false" -os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" - -console = Console() - - -def _validate_oauth_timeout( - _ctx: click.Context, - _param: click.Parameter, - value: float, -) -> float: - if not math.isfinite(value) or value <= 0: - raise click.BadParameter("must be a finite number greater than zero") - return value - - -def _force_screenshot_only_vision(config: MobileConfig) -> None: - config.agent.vision_only = True - config.agent.manager.vision = True - config.agent.executor.vision = True - config.agent.fast_agent.vision = True - - -def _setup_cli_logging(debug: bool) -> None: - """Configure the mobilerun logger with a CLILogHandler.""" - handler = CLILogHandler() - handler.setFormatter( - logging.Formatter("%(levelname)s %(name)s %(message)s", "%H:%M:%S") - if debug - else logging.Formatter("%(message)s") - ) - configure_logging(debug=debug, handler=handler) - - -def coro(f): - @wraps(f) - def wrapper(*args, **kwargs): - return asyncio.run(f(*args, **kwargs)) - - return wrapper - - -async def run_command( - command: str, - config_path: str | None = None, - device: str | None = None, - agent: str | None = None, - provider: str | None = None, - model: str | None = None, - steps: int | None = None, - base_url: str | None = None, - api_base: str | None = None, - vision: bool | None = None, - vision_only: bool | None = None, - manager_vision: bool | None = None, - executor_vision: bool | None = None, - fast_agent_vision: bool | None = None, - reasoning: bool | None = None, - stream: bool | None = None, - tracing: bool | None = None, - debug: bool | None = None, - tcp: bool | None = None, - control_backend: str | None = None, - device_id: str | None = None, - save_trajectory: str | None = None, - ios: bool = False, - temperature: float | None = None, - no_delegate: bool = False, - delegate_model: str | None = None, - **kwargs, -) -> bool: - """Run a command on your Android device using natural language. - - Returns: - bool: True if the task completed successfully, False otherwise. - """ - config = ConfigLoader.load(config_path) - - # Print cloud link in a box - if config.logging.rich_text: - cloud_text = Text() - cloud_text.append("✨ Try Mobilerun Cloud: ", style="bold cyan") - cloud_text.append( - "https://cloud.mobilerun.ai/sign-in", style="bold blue underline" - ) - cloud_panel = Panel( - cloud_text, - border_style="cyan", - padding=(0, 1), - ) - console.print(cloud_panel) - else: - console.print("\n✨ Try Mobilerun Cloud: https://cloud.mobilerun.ai/sign-in\n") - - # Initialize logging - debug_mode = debug if debug is not None else config.logging.debug - _setup_cli_logging(debug_mode) - logger = logging.getLogger("mobilerun") - droid_agent = None - - try: - logger.info(f"🚀 Starting: {command}") - print_telemetry_message(config_enabled=config.telemetry.enabled) - - # ================================================================ - # STEP 1: Apply CLI overrides via direct mutation - # ================================================================ - - # Vision overrides - if vision_only is not None: - config.agent.vision_only = vision_only - - if config.agent.vision_only: - _force_screenshot_only_vision(config) - logger.debug("CLI override: vision_only=True") - elif vision is not None: - # --vision flag overrides all agents - config.agent.manager.vision = vision - config.agent.executor.vision = vision - config.agent.fast_agent.vision = vision - logger.debug(f"CLI override: vision={vision} (all agents)") - else: - # Apply individual agent vision overrides - if manager_vision is not None: - config.agent.manager.vision = manager_vision - if executor_vision is not None: - config.agent.executor.vision = executor_vision - if fast_agent_vision is not None: - config.agent.fast_agent.vision = fast_agent_vision - - # Agent overrides - if agent is not None: - config.agent.name = agent - if steps is not None: - config.agent.max_steps = steps - if reasoning is not None: - config.agent.reasoning = reasoning - if stream is not None: - config.agent.streaming = stream - - if no_delegate: - if delegate_model: - raise ValueError("--delegate-model cannot be used with --no-delegate.") - _drop_delegation(config) - - # A decision model picks actions in place of the action LLM. - if provider and provider.strip().lower() == "typesafe": - # Override the profile that will actually pick actions. - delegation = config.agent.delegation - role = ( - delegation.primary - if delegation is not None and delegation.primary - else _action_role(config) - ) - existing = config.decision_profiles.get(role) - endpoint = base_url or api_base - configured = ( - existing is not None and existing.provider.lower() == "typesafe" - ) - if configured: - config.decision_profiles[role] = replace( - existing, - model=model or existing.model, - base_url=endpoint or existing.base_url, - ) - else: - config.decision_profiles[role] = DecisionProfile( - model=model, base_url=endpoint - ) - # Endpoints given with -p belong to that provider, not the helper LLMs. - provider = model = base_url = api_base = None - # Delegate by default, unless the config already sets Jev up alone. - if not no_delegate and config.agent.delegation is None and not configured: - try: - ensure_llm_delegation(config, role) - except ValueError as error: - logger.warning(f"{error} Jev will run alone.") - if delegate_model: - _apply_delegate_model(config, delegate_model) - if not (provider or model) and config.agent.name in {"mobilerun", "droidrun"}: - _log_delegate(config, logger) - - # Device overrides - if device is not None: - config.device.serial = device - if tcp is not None: - config.device.use_tcp = tcp - if control_backend is not None: - config.device.control_backend = control_backend - if device_id is not None: - config.device.device_id = device_id - - if ( - config.device.control_backend or "" - ).strip().lower() == VISUAL_REMOTE_CONNECTION: - _force_screenshot_only_vision(config) - logger.debug("Control backend visual-remote forces screenshot-only vision") - - # Logging overrides - if debug is not None: - config.logging.debug = debug - if save_trajectory is not None: - config.logging.save_trajectory = save_trajectory - - # Tracing overrides - if tracing is not None: - config.tracing.enabled = tracing - - # Platform overrides - if ios: - config.device.platform = "ios" - if ( - config.device.control_backend or "" - ).lower() == VISUAL_REMOTE_CONNECTION: - pass - elif config.device.serial: - config.device.serial = validate_ios_portal_url(config.device.serial) - else: - logger.info("🔍 Searching for iOS portal...") - config.device.serial = await discover_ios_device() - - # ================================================================ - # STEP 2: Initialize MobileAgent with config - # ================================================================ - - mode = ( - "planning with reasoning" if config.agent.reasoning else "direct execution" - ) - logger.info(f"🤖 Agent mode: {mode}") - logger.info( - f"👁️ Vision settings: Manager={config.agent.manager.vision}, " - f"Executor={config.agent.executor.vision}, FastAgent={config.agent.fast_agent.vision}" - ) - - if config.tracing.enabled: - logger.info("🔍 Tracing enabled") - - # Build MobileAgent kwargs for LLM loading - # Constructor options include strings, numeric overrides, and mappings. - droid_agent_kwargs: dict[str, Any] = {"runtype": "cli"} - llm = None - - if provider or model: - assert ( - provider and model - ), "Either both provider and model must be provided or none of them" - llm_kwargs: dict[str, Any] = {} - if temperature is not None: - llm_kwargs["temperature"] = temperature - if base_url is not None: - llm_kwargs["base_url"] = base_url - if api_base is not None: - llm_kwargs["api_base"] = api_base - llm = load_llm(provider, model=model, **llm_kwargs, **kwargs) - else: - if temperature is not None: - droid_agent_kwargs["temperature"] = temperature - if base_url is not None: - droid_agent_kwargs["base_url"] = base_url - if api_base is not None: - droid_agent_kwargs["api_base"] = api_base - droid_agent_kwargs.update(kwargs) - - droid_agent = MobileAgent( - goal=command, - action_selectors=LlamaIndexLLMSelector(llm) if llm is not None else None, - config=config, - timeout=1000, - **droid_agent_kwargs, - ) - - # ================================================================ - # STEP 3: Run agent - # ================================================================ - - logger.debug("▶️ Starting agent execution...") - logger.debug("Press Ctrl+C to stop") - - event_handler = EventHandler() - - try: - handler = droid_agent.run() - - async for event in handler.stream_events(): - event_handler.handle(event) - result: ResultEvent = await handler - return result.success - - except KeyboardInterrupt: - logger.info("⏹️ Stopped by user") - return False - - except Exception as e: - err_desc = describe_error(e) or type(e).__name__ - logger.error(f"💥 Error: {err_desc}") - if config.logging.debug: - import traceback - - logger.debug(traceback.format_exc()) - return False - - except Exception as e: - err_desc = describe_error(e) or type(e).__name__ - logger.error(f"💥 Setup error: {err_desc}") - if debug_mode: - import traceback - - logger.debug(traceback.format_exc()) - return False - finally: - await _cleanup_android_keyboard( - config, getattr(droid_agent, "android_serial", None) - ) - - -def _action_role(config: MobileConfig) -> str: - return "executor" if config.agent.reasoning else "fast_agent" - - -def _drop_delegation(config: MobileConfig) -> None: - """Turn delegation off, keeping a named decision primary as the action picker.""" - delegation = config.agent.delegation - role = _action_role(config) - if ( - delegation is not None - and delegation.primary - and delegation.primary != role - and delegation.primary in config.decision_profiles - ): - config.decision_profiles[role] = config.decision_profiles[delegation.primary] - elif ( - delegation is not None - and delegation.primary - and delegation.primary != role - and delegation.primary in config.llm_profiles - ): - config.decision_profiles.pop(role, None) - config.llm_profiles[role] = config.llm_profiles[delegation.primary] - config.agent.delegation = None - - -def _apply_delegate_model(config: MobileConfig, spec: str) -> None: - delegation = config.agent.delegation - role = _action_role(config) - if ((delegation and delegation.primary) or role) not in config.decision_profiles: - raise ValueError( - "--delegate-model needs a decision model picking actions: pass " - "-p TypeSafe or set decision_profiles in the config." - ) - ensure_llm_delegation(config, role, _delegate_profile(config, role, spec)) - - -# These providers take vendor-prefixed model ids such as "anthropic/claude". -_VENDOR_MODEL_PROVIDERS = {"openrouter", "openailike"} - - -def _delegate_profile(config: MobileConfig, role: str, spec: str) -> LLMProfile: - """Profile for --delegate-model PROVIDER/MODEL, or MODEL on the agent's provider.""" - spec = spec.strip() - provider, _, model = spec.partition("/") - base = config.llm_profiles.get(role) or config.llm_profiles.get("fast_agent") - base_name = normalize_provider_name(base.provider).lower() if base else "" - name = normalize_provider_name(provider).lower() - family = (getattr(base, "provider_family", None) or "").lower() - if base_name in _VENDOR_MODEL_PROVIDERS and family in {"", "openai_like"}: - # Only an exact provider name switches away from a vendor/model id. - is_provider = provider in _known_providers(exact=True) - else: - is_provider = name in _known_providers() - if model and is_provider: - if name in {base_name, family}: - return replace(base, model=model) - return profile_for_provider(provider, model) - if base is None: - raise ValueError( - "--delegate-model needs PROVIDER/MODEL when the config has no agent LLM." - ) - return replace(base, model=spec) - - -def _known_providers(exact: bool = False) -> set[str]: - names = set(SUPPORTED_PROVIDERS) - for family in list_provider_families(): - names.update(v.runtime_provider_name for v in family.variants) - return names if exact else {name.lower() for name in names} - - -def _log_delegate(config: MobileConfig, logger: logging.Logger) -> None: - delegation = config.agent.delegation - if delegation is None: - return - profile = config.llm_profiles.get(delegation.delegate) - if profile is not None: - logger.info(f"🤝 Delegate: {profile.provider}/{profile.model}") - - -async def _cleanup_android_keyboard( - config: MobileConfig, serial: str | None = None -) -> None: - platform = (config.device.platform or "").lower() - control_backend = (config.device.control_backend or "").lower() - if platform == "ios" or control_backend == VISUAL_REMOTE_CONNECTION: - return - - # Without a selected device the keyboard was never enabled. - serial = serial or config.device.serial - if not serial or is_portal_http_url(serial): - return - - try: - device_obj = await adb.device(serial) - if device_obj: - from mobilerun_core_local.driver.android.portal import ( - PORTAL_PACKAGE_NAME, - portal_ime_id, - ) - - ime = portal_ime_id(PORTAL_PACKAGE_NAME) - await device_obj.shell(f"ime disable {ime}") - except Exception: - click.echo("Failed to disable Mobilerun keyboard") - - -def _print_version(ctx, param, value): - """Click callback to print version and exit early when --version is passed.""" - if not value or ctx.resilient_parsing: - return - version = None - try: - version = importlib.metadata.version("mobilerun") - # print("debug: step 1") - except Exception: - pass - - if not version: - try: - from mobilerun import __version__ as pkg_version - - version = pkg_version - # print("debug: step 2") - except Exception: - pass - - if not version: - try: - repo_root = Path(__file__).resolve().parents[2] - pyproject = repo_root / "pyproject.toml" - if pyproject.exists(): - with pyproject.open("rb") as f: - data = tomllib.load(f) - version = data.get("project", {}).get("version") - # print("debug: step 3") - except Exception: - version = None - - if not version: - version = "unknown" - click.echo(f"v{version}") - ctx.exit() - - -@click.group() -@click.option( - "--version", - is_flag=True, - callback=_print_version, - expose_value=False, - is_eager=True, - help="Show mobilerun version and exit", -) -def cli(): - """Mobilerun - Control your mobile devices through LLM agents.""" - pass - - -def _print_oauth_login_success(provider_label: str, credential_path: str) -> None: - console.print(f"[green]{provider_label} login succeeded.[/]") - console.print( - f"[blue]Credentials saved to:[/] {Path(credential_path).expanduser()}" - ) - - -def _run_openai_oauth_login(credential_path: str, model: str | None, **kwargs) -> None: - run_openai_oauth_login(credential_path=credential_path, model=model, **kwargs) - _print_oauth_login_success("OpenAI", credential_path) - - -def _run_gemini_oauth_login(credential_path: str, model: str | None, **kwargs) -> None: - run_gemini_oauth_login(credential_path=credential_path, model=model, **kwargs) - _print_oauth_login_success("Gemini", credential_path) - - -def _run_anthropic_oauth_login(credential_path: str, **kwargs) -> None: - """Run the full Anthropic OAuth flow inline and save the token.""" - console.print("[blue]Starting Anthropic login...[/]") - run_anthropic_setup_token_oauth( - credential_path=credential_path, - **kwargs, - ) - _print_oauth_login_success("Anthropic", credential_path) - - -def _run_grok_oauth_login( - credential_path: str, model: str | None = None, **kwargs -) -> None: - run_grok_oauth_login( - credential_path=credential_path, - model=model, - **kwargs, - ) - _print_oauth_login_success("XAI", credential_path) - +"""Interrupt-safe CLI import boundary.""" try: - _available_agents = list_agents() -except Exception: - _available_agents = [] - - -@cli.command() -@click.argument("command", type=str) -@click.option("--config", "-c", help="Path to custom config file", default=None) -@click.option( - "--device", - "-d", - help="Device serial number, IP address, or Portal HTTP URL", - default=None, -) -@click.option( - "--agent", - "-a", - type=click.Choice(_available_agents) if _available_agents else None, - help="External agent to use" - + ( - f" [{', '.join(_available_agents)}]" - if _available_agents - else " (none available)" - ), - default=None, -) -@click.option( - "--provider", - "-p", - help="LLM provider (OpenAI, openai_oauth, XAI, Ollama, Anthropic, anthropic_oauth, GoogleGenAI, gemini_oauth_code_assist, DeepSeek), or TypeSafe for the Jev decision model", - default=None, -) -@click.option( - "--model", - "-m", - help="LLM model name (optional for TypeSafe)", - default=None, -) -@click.option( - "--no-delegate", - is_flag=True, - default=False, - help="Let the decision model pick every action, without handing steps to an LLM", -) -@click.option( - "--delegate-model", - default=None, - help="LLM for the decision model's typing and unsure steps, as PROVIDER/MODEL or MODEL (default: the agent's LLM)", -) -@click.option("--temperature", type=float, help="Temperature for LLM", default=None) -@click.option("--steps", type=int, help="Maximum number of steps", default=None) -@click.option( - "--base_url", - "-u", - help="Base URL for API (e.g., OpenRouter or Ollama)", - default=None, -) -@click.option( - "--api_base", - help="Base URL for API (e.g., OpenAI or OpenAI-Like)", - default=None, -) -@click.option( - "--vision/--no-vision", - default=None, - help="Enable vision capabilites by using screenshots for all agents.", -) -@click.option( - "--vision-only/--no-vision-only", - default=None, - help="Use screenshots only without an accessibility tree.", -) -@click.option( - "--reasoning/--no-reasoning", default=None, help="Enable planning with reasoning" -) -@click.option( - "--stream/--no-stream", - default=None, - help="Stream LLM responses to console in real-time", -) -@click.option( - "--tracing/--no-tracing", default=None, help="Enable Arize Phoenix tracing" -) -@click.option("--debug/--no-debug", default=None, help="Enable verbose debug logging") -@click.option( - "--tcp/--no-tcp", - default=None, - help="Use TCP communication for device control", -) -@click.option( - "--control-backend", - type=click.Choice([VISUAL_REMOTE_CONNECTION]), - default=None, - help="Use a compatible visual remote backend instead of the platform default backend.", -) -@click.option( - "--device-id", - default=None, - help="Device id for backends that expose multiple devices.", -) -@click.option( - "--save-trajectory", - type=click.Choice(["none", "step", "action"]), - help="Trajectory saving level: none (no saving), step (save per step), action (save per action)", - default=None, -) -@click.option("--ios", is_flag=True, default=False, help="Run on iOS device") -@coro -async def run( - command: str, - config: str | None, - device: str | None, - agent: str | None, - provider: str | None, - model: str | None, - steps: int | None, - base_url: str | None, - api_base: str | None, - temperature: float | None, - vision: bool | None, - vision_only: bool | None, - reasoning: bool | None, - stream: bool | None, - tracing: bool | None, - debug: bool | None, - tcp: bool | None, - control_backend: str | None, - device_id: str | None, - save_trajectory: str | None, - ios: bool, - no_delegate: bool, - delegate_model: str | None, -): - """Run a command on your mobile device using natural language.""" - - success = await run_command( - command=command, - config_path=config, - device=device, - agent=agent, - provider=provider, - model=model, - steps=steps, - base_url=base_url, - api_base=api_base, - vision=vision, - vision_only=vision_only, - reasoning=reasoning, - stream=stream, - tracing=tracing, - debug=debug, - tcp=tcp, - control_backend=control_backend, - device_id=device_id, - temperature=temperature, - save_trajectory=save_trajectory, - ios=ios, - no_delegate=no_delegate, - delegate_model=delegate_model, - ) - - # Exit with appropriate code - sys.exit(0 if success else 1) - - -async def _list_cloud_devices( - base_url: str | None, - *, - silent_if_no_key: bool = False, - raise_on_error: bool = False, - silent_errors: bool = False, -) -> int: - """List Mobilerun cloud devices via the SDK. - - Returns the number of devices printed. With ``silent_if_no_key=True`` - a missing credential is treated as "no cloud section" rather than an - error — useful for the unified ``mobilerun devices`` output. - """ - api_key = resolve_cloud_api_key() - if not api_key: - if silent_if_no_key: - return 0 - message = ( - f"No cloud API key found. Set {CLOUD_API_KEY_ENV} or run " - "`mobilerun login`." - ) - if raise_on_error: - raise click.ClickException(message) - console.print(f"[red]{message}[/]") - return 0 - - from mobilerun_sdk import AsyncMobilerun - - client = AsyncMobilerun( - api_key=api_key, - base_url=base_url or DEFAULT_CLOUD_BASE_URL, - timeout=30.0, - ) - try: - resp = await client.devices.list() - except Exception as e: - if silent_errors: - return 0 - if raise_on_error: - raise click.ClickException(f"Error listing cloud devices: {e}") from e - console.print(f"[red]Error listing cloud devices: {e}[/]") - return 0 - - items = getattr(resp, "items", None) or [] - if not items: - console.print("[yellow]No cloud devices found.[/]") - return 0 - - console.print(f"[green]Found {len(items)} cloud device(s):[/]") - for item in items: - data = item.model_dump() if hasattr(item, "model_dump") else dict(item) - device_id = data.get("id", "") - name = data.get("name", "") - state = data.get("state", "") - dtype = data.get("type", "") - state_color = "green" if state == "ready" else "yellow" - console.print( - f" • [bold]{device_id}[/] {name} " - f"\\[[{state_color}]{state}[/]] [dim]{dtype}[/]" - ) - return len(items) - - -@cli.command() -@click.option( - "--cloud", - is_flag=True, - default=False, - help="Only list Mobilerun cloud devices", -) -@click.option( - "--base-url", - "base_url", - default=None, - help=f"Cloud API base URL (default {DEFAULT_CLOUD_BASE_URL})", -) -@coro -async def devices(cloud: bool, base_url: str | None): - """List connected devices. - - Without flags: shows local Android devices, plus cloud devices when - a Mobilerun credential is available. ``--cloud`` shows only cloud. - """ - if cloud: - await _list_cloud_devices(base_url, raise_on_error=True) - return - - # Local section - try: - local = await adb.list() - if not local: - console.print("[yellow]No local devices connected.[/]") - else: - console.print(f"[green]Found {len(local)} local device(s):[/]") - for device in local: - console.print(f" • [bold]{device.serial}[/]") - except Exception as e: - console.print(f"[red]Error listing local devices: {e}[/]") - - # Cloud section (only when authenticated — silent otherwise). - if resolve_cloud_api_key(): - console.print() - await _list_cloud_devices( - base_url, - silent_if_no_key=True, - silent_errors=True, - ) - - -DEFAULT_AUTH_URL = "https://cloud.mobilerun.ai/api/auth" -DEFAULT_DEVICE_CLIENT_ID = "mobilerun-cli" - - -@cli.command() -@click.option( - "--auth-url", - "auth_url", - default=None, - help=f"Better-auth base URL (default {DEFAULT_AUTH_URL}; or set MOBILERUN_AUTH_URL)", -) -@click.option( - "--client-id", - "client_id", - default=DEFAULT_DEVICE_CLIENT_ID, - show_default=True, - help="OAuth device client id", -) -def login(auth_url: str | None, client_id: str): - """Authenticate via OAuth 2.0 device authorization and save the session token.""" - import webbrowser - - from mobilerun.cli import deviceauth - - auth_url = auth_url or os.getenv("MOBILERUN_AUTH_URL") or DEFAULT_AUTH_URL - - try: - code = deviceauth.request_code(auth_url, client_id) - except Exception as e: - raise click.ClickException(f"request device code: {e}") from e - - console.print(f"Visit: [bold]{code.verification_uri}[/]") - console.print(f"Code: [bold cyan]{code.user_code}[/]") - if code.verification_uri_complete: - console.print(f"[dim](Opening {code.verification_uri_complete} ...)[/]") - try: - webbrowser.open(code.verification_uri_complete) - except Exception: - pass - console.print( - f"[dim]Waiting for authorization (expires in {code.expires_in}s)...[/]" - ) - - try: - token = deviceauth.poll_token(auth_url, client_id, code) - except deviceauth.DeviceAuthError as e: - raise click.ClickException(f"authorization failed: {e}") from e - except KeyboardInterrupt: - raise click.ClickException("login cancelled") from None - - path = deviceauth.save_token(token, auth_url) - email = None - try: - info = deviceauth.fetch_session(auth_url, token) - if info: - email = info.get("user", {}).get("email") - except Exception: - pass - who = f" as {email}" if email else "" - console.print(f"[green]Logged in{who}.[/] Token saved to {path}") - - -@cli.command() -def logout(): - """Remove the saved Mobilerun cloud login.""" - from mobilerun.cli import deviceauth - - if deviceauth.clear_credentials(): - console.print("[green]Logged out.[/]") - else: - console.print("[yellow]Not logged in.[/]") - - -@cli.command() -def whoami(): - """Show the currently logged-in Mobilerun cloud user.""" - from mobilerun.cli import deviceauth - - creds = deviceauth.load_credentials() - token = (creds or {}).get("access_token") - auth_url = (creds or {}).get("auth_url") or DEFAULT_AUTH_URL - if not token: - raise click.ClickException("Not logged in (run `mobilerun login`).") - try: - info = deviceauth.fetch_session(auth_url, token) - except Exception as e: - raise click.ClickException(f"session check failed: {e}") from e - if info is None: - raise click.ClickException( - "Session not recognized (run `mobilerun login` again)." - ) - user = info.get("user", {}) - console.print(f"{user.get('email', '?')} (id: {user.get('id', '?')})") - - -@cli.command() -@click.argument("serial") -@coro -async def connect(serial: str): - """Connect to a device over TCP/IP.""" - try: - device = await adb.connect(serial) - if device.count("already connected"): - console.print(f"[green]Successfully connected to {serial}[/]") - else: - console.print(f"[red]Failed to connect to {serial}: {device}[/]") - except Exception as e: - console.print(f"[red]Error connecting to device: {e}[/]") - - -@cli.command() -@click.argument("serial") -@coro -async def disconnect(serial: str): - """Disconnect from a device.""" - try: - success = await adb.disconnect(serial, raise_error=True) - if success: - console.print(f"[green]Successfully disconnected from {serial}[/]") - else: - console.print(f"[yellow]Device {serial} was not connected[/]") - except Exception as e: - console.print(f"[red]Error disconnecting from device: {e}[/]") - - -async def _wait_for_portal_service( - device_obj, timeout: float = 30.0, interval: float = 1.0 -) -> bool: - """Wait until Portal's accessibility service responds. - - Android drops the service when it is enabled before a fresh install is - registered, so it is enabled again while waiting. ``/ping`` answers once - the service is bound, even without an active window. A busy device can - take well over 10s to bind it. - """ - uri = portal_content_uri(PORTAL_PACKAGE_NAME, "ping") - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout - while loop.time() < deadline: - try: - result = await device_obj.shell(f"content query --uri {uri}") - if '"status":"success"' in result: - return True - if not await check_portal_accessibility(device_obj): - await enable_portal_accessibility(device_obj) - except Exception: - pass - await asyncio.sleep(interval) - return False - - -async def _setup_portal( - path: str | None, - device: str | None, - debug: bool, - latest: bool = False, - specific_version: str | None = None, - in_place: bool = False, -): - """Internal async function to install and enable the Mobilerun Portal on a device.""" - try: - if not device: - try: - device = await resolve_android_serial() - except AndroidDeviceSelectionError as e: - console.print(f"[bold red]Error:[/] {e}") - return - console.print(f"[blue]Using device:[/] {device}") - - device_obj = await adb.device(device) - if not device_obj: - console.print( - f"[bold red]Error:[/] Could not get device object for {device}" - ) - return - - # CLI-specific options: path, specific_version, latest - if path: - console.print(f"[bold blue]Using provided APK:[/] {path}") - apk_context = nullcontext(path) - elif specific_version: - version = specific_version.lstrip("v") - version = f"v{version}" - download_base = DOWNLOAD_BASE - apk_context = download_versioned_portal_apk(version, download_base, debug) - elif latest: - console.print("[bold blue]Downloading latest Portal APK...[/]") - apk_context = download_portal_apk(debug) - else: - # Default: delegate to shared setup_portal() - success = await setup_portal( - device_obj, - debug, - **portal_version_kwargs(setup_portal, in_place=in_place), - ) - if success: - console.print( - "\n[bold green]Setup complete![/] The Mobilerun Portal is now installed and ready to use." - ) - else: - console.print( - "[bold red]Setup failed.[/] Run 'mobilerun doctor' for diagnostics." - ) - return - - # Install from explicit path/version/latest - with apk_context as apk_path: - if not os.path.exists(apk_path): - console.print(f"[bold red]Error:[/] APK file not found at {apk_path}") - return - - console.print(f"[bold blue]Step 1/2: Installing APK:[/] {apk_path}") - try: - await device_obj.install( - apk_path, - nolaunch=True, - uninstall=True, - flags=["-g"], - silent=not debug, - ) - except Exception as e: - console.print(f"[bold red]Installation failed:[/] {e}") - return - - console.print("[bold green]Installation successful![/]") - - console.print("[bold blue]Step 2/2: Enabling accessibility service[/]") - - try: - await enable_portal_accessibility(device_obj) - if not await _wait_for_portal_service(device_obj): - raise RuntimeError("the service did not respond in time") - - console.print("[green]Accessibility service enabled successfully![/]") - console.print( - "\n[bold green]Setup complete![/] The Mobilerun Portal is now installed and ready to use." - ) - - except Exception as e: - console.print( - f"[yellow]Could not automatically enable accessibility service: {e}[/]" - ) - console.print( - "[yellow]Opening accessibility settings for manual configuration...[/]" - ) - - await device_obj.shell( - "am start -a android.settings.ACCESSIBILITY_SETTINGS" - ) - - console.print( - "\n[yellow]Please complete the following steps on your device:[/]" - ) - console.print( - f"1. Find [bold]{PORTAL_PACKAGE_NAME}[/] in the accessibility services list" - ) - console.print("2. Tap on the service name") - console.print( - "3. Toggle the switch to [bold]ON[/] to enable the service" - ) - console.print("4. Accept any permission dialogs that appear") - - console.print( - "\n[bold green]APK installation complete![/] Please manually enable the accessibility service using the steps above." - ) - - except Exception as e: - console.print(f"[bold red]Error:[/] {e}") - - if debug: - import traceback - - traceback.print_exc() - - -@cli.command() -@click.option("--device", "-d", help="Device serial number or IP address", default=None) -@click.option( - "--path", - help="Path to the Mobilerun Portal APK to install on the device. If not provided, the latest portal apk version will be downloaded and installed.", - default=None, -) -@click.option( - "--portal-version", - "-pv", - help="Specific Portal version to install (e.g., 0.4.7)", - default=None, -) -@click.option( - "--latest", - is_flag=True, - help="Install latest Portal instead of compatible version", - default=False, -) -@click.option( - "--debug", is_flag=True, help="Enable verbose debug logging", default=False -) -@coro -async def setup( - path: str | None, - device: str | None, - portal_version: str | None, - latest: bool, - debug: bool, -): - """Install and enable the Mobilerun Portal on a device.""" - await _setup_portal(path, device, debug, latest, portal_version) - - -@cli.command() -@click.option( - "--device", - "-d", - help="Device serial number, IP address, or Portal HTTP URL", - default=None, -) -@click.option( - "--tcp/--no-tcp", - default=None, - help="Use TCP communication for device control", -) -@click.option("--debug/--no-debug", default=None, help="Enable verbose debug logging") -@coro -async def ping(device: str | None, tcp: bool | None, debug: bool | None): - """Ping a device to check if it is ready and accessible.""" - # Handle None defaults - debug_mode = debug if debug is not None else False - use_tcp_mode = tcp if tcp is not None else False - - try: - if is_portal_http_url(device): - token = ConfigLoader.load(None).device.resolve_auth_token() - driver = create_android_portal_http_driver(device, token) - await driver.connect() - await driver.get_ui_tree() - console.print( - "[bold green]Portal HTTP is reachable and serving the UI tree. " - "You're good to go![/]" - ) - return - - device_obj = await adb.device(device) - if not device_obj: - console.print(f"[bold red]Error:[/] Could not find device {device}") - return - - await ping_portal(device_obj, debug_mode) - - if use_tcp_mode: - await ping_portal_tcp(device_obj, debug_mode) - else: - await ping_portal_content(device_obj, debug_mode) - - console.print( - "[bold green]Portal is installed and accessible. You're good to go![/]" - ) - except Exception as e: - console.print(f"[bold red]Error:[/] {e}") - if debug_mode: - import traceback - - traceback.print_exc() - - -# Add macro commands as a subgroup -cli.add_command(macro_cli, name="macro") - -# Add device action commands as a subgroup -cli.add_command(device_cli, name="device") - - -@cli.command() -@click.option("--device", "-d", help="Device serial number or IP address", default=None) -@click.option("--debug/--no-debug", default=None, help="Enable verbose debug output") -@coro -async def doctor(device: str | None, debug: bool | None): - """Check system health and diagnose issues.""" - from mobilerun.cli.doctor import run_doctor - - await run_doctor(device, debug if debug is not None else False) - - -@cli.group(name="configure", invoke_without_command=True) -@click.option( - "--provider", - type=str, - default=None, - help="Provider family (gemini, openai, anthropic, XAI, ollama, openai_like, minimax, zai).", -) -@click.option( - "--auth-mode", - type=str, - default=None, - help="Auth mode for the selected provider family.", -) -@click.option("--model", type=str, default=None, help="Model to configure.") -@click.option( - "--api-key", type=str, default=None, help="API key for API-key providers." -) -@click.option( - "--base-url", - type=str, - default=None, - help="Base URL override for compatible providers.", -) -@click.pass_context -def configure( - ctx: click.Context, - provider: str | None, - auth_mode: str | None, - model: str | None, - api_key: str | None, - base_url: str | None, -): - """Configure LLM provider, auth mode, and model. - - Run without a subcommand for the interactive wizard, or use a provider - subcommand (e.g. `mobilerun configure anthropic`) to log in via OAuth. - """ - if ctx.invoked_subcommand is not None: - return - run_configure_wizard( - console, - ConfigureWizardCallbacks( - run_openai_oauth_login=_run_openai_oauth_login, - run_anthropic_oauth_login=_run_anthropic_oauth_login, - run_gemini_oauth_login=_run_gemini_oauth_login, - run_grok_oauth_login=_run_grok_oauth_login, - ), - provider=provider, - auth_mode=auth_mode, - model=model, - api_key=api_key, - base_url=base_url, - ) - - -@configure.command("openai") -@click.option( - "--credential-path", - default=str(DEFAULT_OPENAI_OAUTH_CREDENTIAL_PATH), - show_default=True, - help="Where to store OpenAI OAuth credentials.", -) -@click.option( - "--model", default=None, help="Optional model override for later API calls." -) -@click.option( - "--timeout", - type=float, - callback=_validate_oauth_timeout, - default=300.0, - show_default=True, - help="Max seconds allowed for the complete OpenAI OAuth login.", -) -@click.option( - "--callback-host", - default=DEFAULT_OPENAI_OAUTH_CALLBACK_HOST, - show_default=True, - help="Host to bind the local OAuth callback server.", -) -@click.option( - "--callback-port", - type=int, - default=DEFAULT_OPENAI_OAUTH_CALLBACK_PORT, - show_default=True, - help="Port to bind the local OAuth callback server.", -) -@click.option( - "--callback-path", - default=DEFAULT_OPENAI_OAUTH_CALLBACK_PATH, - show_default=True, - help="Callback path for the local OAuth server.", -) -@click.option( - "--open-browser/--no-browser", - default=True, - show_default=True, - help="Open the authorization URL automatically.", -) -def configure_openai( - credential_path: str, - model: str | None, - timeout: float, - callback_host: str, - callback_port: int, - callback_path: str, - open_browser: bool, -): - """Log in to ChatGPT/OpenAI via OAuth and save credentials locally.""" - _run_openai_oauth_login( - credential_path=credential_path, - model=model, - timeout=timeout, - callback_host=callback_host, - callback_port=callback_port, - callback_path=callback_path, - open_browser=open_browser, - ) - - -@configure.command("anthropic") -@click.option( - "--credential-path", - default=str(ANTHROPIC_OAUTH_CREDENTIAL_PATH), - show_default=True, - help="Where to store the Anthropic credentials.", -) -@click.option( - "--token", - default=None, - help="Anthropic setup-token value. If provided, skips the OAuth flow.", -) -@click.option( - "--timeout", - type=float, - callback=_validate_oauth_timeout, - default=300.0, - show_default=True, - help="Max seconds allowed for the complete Anthropic OAuth login.", -) -@click.option( - "--open-browser/--no-browser", - default=True, - show_default=True, - help="Open the Anthropic authorization URL automatically.", -) -def configure_anthropic( - credential_path: str, - token: str | None, - timeout: float, - open_browser: bool, -): - """Log in to Anthropic via OAuth (or pass --token to save a setup-token).""" - if token: - save_anthropic_setup_token(credential_path, token) - _print_oauth_login_success("Anthropic", credential_path) - else: - _run_anthropic_oauth_login( - credential_path=credential_path, - timeout=timeout, - open_browser=open_browser, - ) - - -@configure.command("gemini") -@click.option( - "--credential-path", - default=str(GEMINI_OAUTH_CREDENTIAL_PATH), - show_default=True, - help="Where to store Gemini OAuth credentials.", -) -@click.option( - "--model", default=None, help="Optional model override for later API calls." -) -@click.option( - "--timeout", - type=float, - callback=_validate_oauth_timeout, - default=300.0, - show_default=True, - help="Max seconds allowed for the complete Gemini OAuth login.", -) -@click.option( - "--callback-host", - default="127.0.0.1", - show_default=True, - help="Host to bind the local OAuth callback server.", -) -@click.option( - "--callback-port", - type=int, - default=0, - show_default=True, - help="Port to bind the local OAuth callback server. Use 0 for auto.", -) -@click.option( - "--callback-path", - default="/oauth2callback", - show_default=True, - help="Callback path for the local OAuth server.", -) -@click.option( - "--open-browser/--no-browser", - default=True, - show_default=True, - help="Open the authorization URL automatically.", -) -def configure_gemini( - credential_path: str, - model: str | None, - timeout: float, - callback_host: str, - callback_port: int, - callback_path: str, - open_browser: bool, -): - """Log in to Gemini Code Assist via OAuth and save credentials locally.""" - _run_gemini_oauth_login( - credential_path=credential_path, - model=model, - timeout=timeout, - callback_host=callback_host, - callback_port=callback_port, - callback_path=callback_path, - open_browser=open_browser, - ) - - -@configure.command("xai") -@click.option( - "--credential-path", - default=str(GROK_OAUTH_CREDENTIAL_PATH), - show_default=True, - help="Where to store XAI OAuth credentials.", -) -@click.option( - "--model", default=None, help="Optional model override for later API calls." -) -@click.option( - "--timeout", - type=float, - callback=_validate_oauth_timeout, - default=300.0, - show_default=True, - help="Max seconds allowed for the complete xAI OAuth login.", -) -@click.option( - "--open-browser/--no-browser", - default=True, - show_default=True, - help="Open the xAI authorization URL automatically.", -) -@click.option( - "--device-code", - is_flag=True, - default=False, - help="Use xAI's device-code flow for SSH or other headless environments.", -) -def configure_xai( - credential_path: str, - model: str | None, - timeout: float, - open_browser: bool, - device_code: bool, -): - """Log in to XAI with Mobilerun OAuth.""" - _run_grok_oauth_login( - credential_path=credential_path, - model=model, - timeout=timeout, - open_browser=open_browser, - device_code=device_code, - ) - - -async def test( - command: str, - config_path: str | None = None, - device: str | None = None, - steps: int | None = None, - vision: bool | None = None, - reasoning: bool | None = None, - tracing: bool | None = None, - debug: bool | None = None, - use_tcp: bool | None = None, - save_trajectory: str | None = None, - temperature: float | None = None, - ios: bool = False, -): - config = ConfigLoader.load(config_path) - - # Initialize logging - debug_mode = debug if debug is not None else config.logging.debug - _setup_cli_logging(debug_mode) - logger = logging.getLogger("mobilerun") - - try: - logger.info(f"🚀 Starting: {command}") - print_telemetry_message(config_enabled=config.telemetry.enabled) - - # ================================================================ - # STEP 1: Apply CLI overrides via direct mutation - # ================================================================ - - # Vision overrides - if vision is not None: - # --vision flag overrides all agents - config.agent.manager.vision = vision - config.agent.executor.vision = vision - config.agent.fast_agent.vision = vision - logger.debug(f"CLI override: vision={vision} (all agents)") - - # Agent overrides - if steps is not None: - config.agent.max_steps = steps - if reasoning is not None: - config.agent.reasoning = reasoning - - # Device overrides - if device is not None: - config.device.serial = device - if use_tcp is not None: - config.device.use_tcp = use_tcp - - # Logging overrides - if debug is not None: - config.logging.debug = debug - if save_trajectory is not None: - config.logging.save_trajectory = save_trajectory - - # Tracing overrides - if tracing is not None: - config.tracing.enabled = tracing - - # Platform overrides - if ios: - config.device.platform = "ios" - - # ================================================================ - # STEP 2: Initialize MobileAgent with config - # ================================================================ - - mode = ( - "planning with reasoning" if config.agent.reasoning else "direct execution" - ) - logger.info(f"🤖 Agent mode: {mode}") - logger.info( - f"👁️ Vision settings: Manager={config.agent.manager.vision}, " - f"Executor={config.agent.executor.vision}, FastAgent={config.agent.fast_agent.vision}" - ) - - if config.tracing.enabled: - logger.info("🔍 Tracing enabled") - - # Build MobileAgent kwargs for LLM loading - droid_agent_kwargs = {} - if temperature is not None: - droid_agent_kwargs["temperature"] = temperature - - droid_agent = MobileAgent( - goal=command, - config=config, - timeout=1000, - **droid_agent_kwargs, - ) - - # ================================================================ - # STEP 3: Run agent - # ================================================================ - - logger.debug("▶️ Starting agent execution...") - logger.debug("Press Ctrl+C to stop") - - event_handler = EventHandler() - - try: - handler = droid_agent.run() - - async for event in handler.stream_events(): - event_handler.handle(event) - result = await handler # noqa: F841 - - except KeyboardInterrupt: - logger.info("⏹️ Stopped by user") - - except Exception as e: - logger.error(f"💥 Error: {describe_error(e)}") - if config.logging.debug: - import traceback - - logger.debug(traceback.format_exc()) - - except Exception as e: - logger.error(f"💥 Setup error: {e}") - if debug_mode: - import traceback - - logger.debug(traceback.format_exc()) - - -if __name__ == "__main__": - command = "open youtube and play a song by shakira" - command = "use open_app to open the settings and search for the battery and enter the first result" - device = None - provider = "OpenAIResponses" - model = "gpt-6-astra" - temperature = 1 - api_key = os.getenv("OPENAI_API_KEY") - steps = 15 - vision = True - reasoning = False - tracing = True - debug = True - use_tcp = False - base_url = None - api_base = None - ios = False - save_trajectory = "none" - asyncio.run(run_command(command, device="emulator-5556", reasoning=False)) + from mobilerun.cli.tree import cli as cli + from mobilerun.cli.tree import run_command as run_command +except KeyboardInterrupt: + raise SystemExit(130) from None diff --git a/mobilerun/cli/oauth_actions.py b/mobilerun/cli/oauth_actions.py index 2ffd1144..99fcf03b 100644 --- a/mobilerun/cli/oauth_actions.py +++ b/mobilerun/cli/oauth_actions.py @@ -1,8 +1,11 @@ from __future__ import annotations +import os + from mobilerun.agent.utils.oauth.anthropic_oauth_llm import ( DEFAULT_SETUP_TOKEN_SCOPE, AnthropicOAuthLLM, + _is_headless_environment, ) from mobilerun.agent.utils.oauth.gemini_oauth_code_assist_llm import ( DEFAULT_MODEL as GEMINI_OAUTH_DEFAULT_MODEL, @@ -197,3 +200,11 @@ def run_anthropic_oauth_setup(credential_path: str) -> None: def get_default_openai_credential_path() -> str: return str(DEFAULT_OPENAI_OAUTH_CREDENTIAL_PATH) + + +def login_requires_input(service): + """Use each provider's existing flow selection to identify manual prompts.""" + return service in ("anthropic", "gemini") and ( + _is_headless_environment() + or os.environ.get("MOBILERUN_OAUTH_MANUAL", "").lower() in {"1", "true", "yes"} + ) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py new file mode 100644 index 00000000..537f6129 --- /dev/null +++ b/mobilerun/cli/options.py @@ -0,0 +1,619 @@ +"""Shared Click options and command IO policy.""" + +from __future__ import annotations + +import asyncio +import difflib +import json +import logging +import os +import sys +import traceback +import unicodedata +from contextlib import redirect_stdout +from functools import wraps + +import click + +DEFAULT_API_URL = "https://api.mobilerun.ai/v1" +DEFAULT_AUTH_URL = "https://cloud.mobilerun.ai/api/auth" +CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"], "show_default": True} +SECRETS_KEY = "cli_secrets" +INPUT_CLOSED = "Input closed; run the command again in a terminal." +# Short secrets would also redact ordinary words in diagnostics. +MIN_REDACTABLE_SECRET = 8 +NO_LOCAL_DEVICES_HINT = ( + "Connect a device over USB or run `mobilerun device pair`; " + "use `-t cloud` for cloud devices." +) + +NO_CLOUD_DEVICES_HINT = ( + "Create or start a device in Mobilerun Cloud and check your sign-in." +) + + +ADB_HINT = ( + "adb was not found or is not running. Install Android SDK Platform-Tools " + "(https://developer.android.com/studio/releases/platform-tools) " + "and check `mobilerun doctor`." +) + + +def config_debug(path): + """Read logging policy without creating or migrating configuration.""" + import yaml + + try: + with path.open(encoding="utf-8") as stream: + data = yaml.safe_load(stream) + logging_config = data.get("logging") if isinstance(data, dict) else None + return isinstance(logging_config, dict) and logging_config.get("debug") is True + except (OSError, ValueError, yaml.YAMLError): + return False + + +def local_device_hint(message, target="local"): + """Add the recovery step for known local discovery errors.""" + lowered = message.lower() + if any( + text in lowered + for text in ( + "no connected android devices", + "no devices", + "no device connected", + ) + ): + return ( + message + + "\n" + + (NO_CLOUD_DEVICES_HINT if target == "cloud" else NO_LOCAL_DEVICES_HINT) + ) + if "unknown device" in lowered or ("device" in lowered and "not found" in lowered): + command = ( + "mobilerun device list -t cloud" + if target == "cloud" + else "mobilerun device list" + ) + return message + f"\nFind connected devices with `{command}`." + return message + + +def _caused_by_interrupt(exc): + return isinstance(exc, KeyboardInterrupt) or isinstance( + exc.__cause__ or exc.__context__, + KeyboardInterrupt, + ) + + +def read_secret(ctx, param, value): + """Accept one secret line from stdin, without echoing it.""" + if ctx.resilient_parsing: + return value + if value == "-": + stream = click.get_text_stream("stdin") + try: + value = ( + click.prompt( + param.human_readable_name, + hide_input=True, + err=True, + default="", + show_default=False, + ) + if stream.isatty() + else stream.readline() + ).rstrip("\r\n") + except (KeyboardInterrupt, click.Abort) as exc: + if _caused_by_interrupt(exc): + raise click.exceptions.Exit(130) from None + raise click.ClickException(INPUT_CLOSED) from None + if not value.strip(): + raise click.BadParameter( + "stdin is empty; provide one secret line.", ctx=ctx, param=param + ) + if value: + ctx.meta.setdefault(SECRETS_KEY, []).append(value) + return value + + +def redact(text, secrets): + for secret in sorted( + {value for value in secrets if value and len(value) >= MIN_REDACTABLE_SECRET}, + key=len, + reverse=True, + ): + text = text.replace(secret, "[redacted]") + return text + + +def redact_secrets(text, ctx): + return redact(text, ctx.meta.get(SECRETS_KEY, ())) + + +class SecretFormatter(logging.Formatter): + def __init__(self, ctx): + super().__init__() + self.ctx = ctx + + def format(self, record): + return redact_secrets(super().format(record), self.ctx) + + +def input_allowed(no_input=False): + return not no_input and sys.stdin.isatty() + + +def _sdk_error(exc): + from mobilerun_sdk import APIConnectionError, APIStatusError + + while exc is not None: + if isinstance(exc, (APIStatusError, APIConnectionError)): + return exc + exc = exc.__cause__ + return None + + +def convert_api_error(exc): + """Return safe text, JSON and method/path diagnostics for an SDK error.""" + from mobilerun_sdk import APIStatusError, APITimeoutError + + exc = _sdk_error(exc) + if exc is None: + return None + request = exc.request + from mobilerun_core.connection.cloud import BEARER_ENV_VAR, LEGACY_BEARER_ENV_VAR + + sensitive = [ + os.environ.get(name, "") for name in (BEARER_ENV_VAR, LEGACY_BEARER_ENV_VAR) + ] + ctx = click.get_current_context(silent=True) + if ctx: + sensitive.extend(ctx.meta.get(SECRETS_KEY, ())) + authorization = request.headers.get("authorization", "") + if authorization.lower().startswith("bearer "): + sensitive.append(authorization[7:]) + + def clean(value): + return redact(" ".join(str(value).split()), sensitive) + + status = exc.status_code if isinstance(exc, APIStatusError) else None + body = exc.body if isinstance(exc.body, dict) else {} + first = ( + (body.get("errors") or [None])[0] + if isinstance(body.get("errors"), list) + else None + ) + fields = (body, first) if isinstance(first, dict) else (body,) + message = next( + ( + entry[key] + for entry in fields + for key in ("detail", "title", "message") + if isinstance(entry.get(key), str) and entry[key].strip() + ), + None, + ) + code = next( + ( + entry["code"] + for entry in fields + if isinstance(entry.get("code"), (str, int)) + ), + None, + ) + if message is None: + if status is not None: + message = exc.response.reason_phrase + elif isinstance(exc, APITimeoutError): + message = "Request timed out" + else: + message = "Could not connect to the API" + message = clean(message or "API request failed") + document = { + "error": message, + "status": status, + "code": clean(code) if code is not None else None, + } + text = f"{message} (HTTP {status})" if status is not None else message + hint = None + if status in {401, 403}: + from mobilerun.cli.selection import ( + CLOUD_CREDENTIAL_LABELS, + resolve_cloud_credential, + ) + + credential = resolve_cloud_credential() + document["credential"] = credential.source + if credential.source is None: + hint = "Run `mobilerun auth login` or set MOBILERUN_API_KEY." + elif status == 403: + label = CLOUD_CREDENTIAL_LABELS[credential.source] + hint = ( + f"The {label} is valid but not allowed to do this; " + "check its scopes or the device owner." + ) + elif credential.source == "env": + hint = f"The key in {credential.env_var} was rejected. " + if credential.saved_sign_in: + hint += "A saved sign-in exists; unset the variable to use it." + else: + hint += "Check the key, or unset the variable and run `mobilerun auth login`." + else: + hint = "The saved sign-in was rejected or has expired. Run `mobilerun auth login`." + elif status == 404 and "devices" in request.url.path.split("/"): + hint = "Find device ids with `mobilerun device list -t cloud`." + elif status is None: + host = request.url.host + if ":" in host: + host = f"[{host}]" + origin = f"{request.url.scheme}://{host}" + if request.url.port is not None: + origin += f":{request.url.port}" + hint = f"Check the API URL from --api-url or MOBILERUN_API_BASE_URL: {clean(origin)}." + if hint: + text += f"\n{hint}" + diagnostic = f"{request.method} {clean(request.url.path)}" + return text, document, diagnostic + + +_REMOVED_FLAGS = { + "--base_url": "-u/--llm-url", + "--api_base": "-u/--llm-url", + "--cloud": "-t cloud", + "--ios": "-P ios", + "--debug": "-v", + "--device-id": "-d", + "--steps": "-s/--max-steps", + "-pv": "--portal-version", + "--control-backend": "--backend", +} + + +class HelpLayout: + """Keep shared flags and complete command summaries consistent.""" + + removed_flags = _REMOVED_FLAGS + dash_hint = False + + def __init__(self, *args, dash_hint=False, removed_flags=None, **kwargs): + self.dash_hint = dash_hint + if removed_flags is not None: + self.removed_flags = removed_flags + kwargs.setdefault("context_settings", CONTEXT_SETTINGS) + super().__init__(*args, **kwargs) + + def parse_args(self, ctx, args): + legacy_cluster = "-pv" in args + try: + return super().parse_args(ctx, args) + except click.NoSuchOption as exc: + flag = ( + "-pv" + if exc.option_name in {"p", "-p"} and legacy_cluster + else exc.option_name + ) + replacement = self.removed_flags.get(flag) + if replacement: + raise click.UsageError( + f"Removed option: {flag}. Use {replacement} instead.", + ctx, + ) from None + if self.dash_hint: + exc.message += "\nPut `--` before arguments that start with a dash." + raise + + def get_params(self, ctx): + def rank(param): + order = ( + "device", + "target", + "platform", + "config_path", + None, + "api_url", + "json_output", + "verbose", + "help", + ) + return order.index(param.name if param.name in order else None) + + return sorted(super().get_params(ctx), key=rank) + + def get_short_help_str(self, limit=45): + return self.short_help or " ".join( + (self.help or "").split("\n\n", 1)[0].split() + ) + + +def _config_path(ctx): + from mobilerun.config_manager import ConfigLoader + + try: + return ConfigLoader.resolve_path(ctx.params.get("config_path")) + except (OSError, ValueError): + if ctx.command.name != "doctor": + raise + return ConfigLoader.get_user_config_path() + + +class Command(HelpLayout, click.Command): + def invoke(self, ctx): + ctx.ensure_object(dict) + ctx.obj["stdout"] = sys.stdout + ctx.obj["emitted"] = False + if ctx.command.name != "completion" and ctx.params.get("verbose") is None: + parent = ctx.parent + while parent: + if parent.params.get("verbose") is not None: + ctx.params["verbose"] = parent.params["verbose"] + break + parent = parent.parent + from mobilerun.log_handlers import configure_logging + + logger = logging.getLogger("mobilerun") + previous = logger.handlers[:], logger.level, logger.propagate + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(SecretFormatter(ctx)) + try: + # Libraries and OAuth flows may print progress directly. + with redirect_stdout(sys.stderr): + if ctx.command.name != "completion": + path = _config_path(ctx) + if ctx.params.get("verbose") is None: + ctx.params["verbose"] = config_debug(path) + configure_logging(bool(ctx.params.get("verbose")), handler) + return super().invoke(ctx) + except (KeyboardInterrupt, asyncio.CancelledError): + ctx.exit(130) + except (EOFError, click.Abort) as exc: + if _caused_by_interrupt(exc): + ctx.exit(130) + raise click.ClickException(INPUT_CLOSED) from None + except click.exceptions.Exit: + raise + except Exception as exc: + converted = convert_api_error(exc) + if converted: + text, document, diagnostic = converted + if ctx.params.get("verbose"): + click.echo(diagnostic, err=True) + exc = click.ClickException(text) + else: + if not isinstance(exc, click.ClickException): + if ctx.params.get("verbose"): + click.echo( + redact_secrets(traceback.format_exc(), ctx), err=True + ) + exc = click.ClickException(str(exc) or type(exc).__name__) + exc.message = local_device_hint( + redact_secrets(exc.message, ctx), + ctx.params.get("target", "local"), + ) + document = {"error": exc.message} + if ( + not isinstance(exc, click.UsageError) + and ctx.params.get("json_output") + and not ctx.obj["emitted"] + ): + emit(document, json_output=True) + raise exc + finally: + logger.handlers, level, logger.propagate = previous + logger.setLevel(level) + + +_RENAMED_COMMANDS = { + "devices": ("mobilerun", "device", "list"), + "whoami": ("mobilerun", "auth", "status"), +} + + +def _command_paths(command, path=("mobilerun",)): + """Enumerate registered command paths without invoking callbacks.""" + yield path, command + if isinstance(command, click.Group): + for name, child in command.commands.items(): + if not child.hidden: + yield from _command_paths(child, (*path, name)) + + +def _follow(path, command, words): + for word in words: + if not isinstance(command, click.Group) or word not in command.commands: + break + path = (*path, word) + command = command.commands[word] + return path + + +class Group(HelpLayout, click.Group): + command_class = Command + group_class = type + + def __init__(self, *args, command_order=None, **kwargs): + self.command_order = command_order + super().__init__(*args, **kwargs) + + def list_commands(self, ctx): + names = super().list_commands(ctx) + if self.command_order: + return [name for name in self.command_order if name in names] + [ + name for name in names if name not in self.command_order + ] + return names + + def resolve_command(self, ctx, args): + try: + return super().resolve_command(ctx, args) + except click.UsageError as exc: + if ( + not ctx.resilient_parsing + and args + and exc.message.startswith("No such command") + ): + matches = self.suggest_commands(ctx, args[0]) + if matches: + suggestions = ", ".join( + f"'{" ".join(_follow(path, command, args[1:]))}'" + for path, command in matches + ) + exc.message += f" Did you mean {suggestions}?" + raise + + def suggest_commands(self, ctx, name): + paths = list(_command_paths(ctx.find_root().command)) + exact = [(path, command) for path, command in paths if path[-1] == name] + if exact: + return exact + if name in _RENAMED_COMMANDS: + return [ + (path, command) + for path, command in paths + if path == _RENAMED_COMMANDS[name] + ] + siblings = difflib.get_close_matches( + name, self.list_commands(ctx), n=1, cutoff=0.6 + ) + if siblings: + parent_path = next(path for path, command in paths if command is self) + return [((*parent_path, siblings[0]), self.commands[siblings[0]])] + closest = difflib.get_close_matches( + name, [path[-1] for path, _ in paths], n=1, cutoff=0.6 + ) + if closest: + return [ + next( + (path, command) for path, command in paths if path[-1] == closest[0] + ) + ] + return [] + + +# The same declarations are reused everywhere, including groups. +no_input_option = click.option( + "--no-input", is_flag=True, help="Never prompt for input." +) +verbose_option = click.option( + "-v", + "--verbose", + is_flag=True, + default=None, + envvar="MOBILERUN_DEBUG", + show_envvar=True, + help="Enable verbose logs.", +) +config_option = click.option( + "-c", + "--config", + "config_path", + type=click.Path(dir_okay=False), + envvar="MOBILERUN_CONFIG", + show_envvar=True, + help="Config file.", +) +device_option = click.option( + "-d", + "--device", + envvar="MOBILERUN_DEVICE", + show_envvar=True, + help="ADB serial, Portal URL, or cloud device id.", +) +target_option = click.option( + "-t", + "--target", + type=click.Choice(["local", "cloud"]), + default="local", + envvar="MOBILERUN_TARGET", + show_envvar=True, + show_default=True, + help="Where to look for devices when no device is specified.", +) +platform_option = click.option( + "-P", + "--platform", + type=click.Choice(["android", "ios"]), + default=None, + help="Device platform (otherwise use config).", +) +api_url_option = click.option( + "--api-url", + default=DEFAULT_API_URL, + envvar="MOBILERUN_API_BASE_URL", + show_envvar=True, + show_default=True, + help="Cloud API URL.", +) +auth_url_option = click.option( + "--auth-url", + default=DEFAULT_AUTH_URL, + envvar="MOBILERUN_AUTH_URL", + show_envvar=True, + help="Mobilerun sign-in URL.", +) +tcp_option = click.option("--tcp/--no-tcp", default=None, help="Use local Android TCP.") +json_option = click.option( + "-j", "--json", "json_output", is_flag=True, help="Print one JSON document." +) +model_option = click.option("-m", "--model", help="LLM model.") +llm_url_option = click.option("-u", "--llm-url", help="LLM API URL.") +max_steps_option = click.option("-s", "--max-steps", type=int, help="Maximum steps.") + + +def device_options(function): + for option in ( + device_option, + target_option, + platform_option, + config_option, + api_url_option, + verbose_option, + ): + function = option(function) + return function + + +def coro(function): + @wraps(function) + def wrapper(*args, **kwargs): + return asyncio.run(function(*args, **kwargs)) + + return wrapper + + +def emit(value, *, json_output=False): + """Write data to the original stdout, even inside a progress redirect.""" + ctx = click.get_current_context(silent=True) + output = stdout() + click.echo(json.dumps(value, default=str) if json_output else value, file=output) + if ctx and ctx.obj is not None and json_output: + ctx.obj["emitted"] = True + + +def _ui_cell(value): + return "".join( + " " if unicodedata.category(char) in {"Cc", "Cf", "Zl", "Zp"} else char + for char in str(value if value is not None else "") + ) + + +def emit_rows(headers, rows): + """Print tab-separated data, adding a header only on terminal stdout.""" + output = stdout() + + def line(values): + return "\t".join(_ui_cell(value) for value in values) + + if output.isatty(): + emit(line(headers)) + for row in rows: + emit(line(row)) + + +def stdout(): + ctx = click.get_current_context(silent=True) + return ctx.obj.get("stdout", sys.stdout) if ctx and ctx.obj else sys.stdout + + +def examples(*lines, docs=None): + text = "\b\nExamples:\n" + "\n".join(" " + line for line in lines) + return text + (f"\n\nDocs: {docs}" if docs else "") diff --git a/mobilerun/cli/providers.py b/mobilerun/cli/providers.py new file mode 100644 index 00000000..216c89dd --- /dev/null +++ b/mobilerun/cli/providers.py @@ -0,0 +1,108 @@ +"""Public provider spellings shared by run and configuration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import click + + +@dataclass(frozen=True) +class Provider: + family: str | None + auth_mode: str + url_keyword: str + run_only: str | None = None + + @property + def runtime(self): + from mobilerun.agent.providers.registry import resolve_provider_variant + + return ( + resolve_provider_variant(self.family, self.auth_mode).id + if self.family + else self.run_only + ) + + +PROVIDERS = { + "openai": Provider("openai", "api_key", "api_base"), + "openai-oauth": Provider("openai", "oauth", "api_base"), + "anthropic": Provider("anthropic", "api_key", "base_url"), + "anthropic-oauth": Provider("anthropic", "oauth", "api_base"), + "gemini": Provider("gemini", "api_key", "http_options"), + "gemini-oauth": Provider("gemini", "oauth", "code_assist_endpoint"), + "xai": Provider("xai", "api_key", "api_base"), + "ollama": Provider("ollama", "none", "base_url"), + "openai-like": Provider("openai_like", "api_key", "base_url"), + "minimax": Provider("minimax", "api_key", "base_url"), + "zai": Provider("zai", "api_key", "base_url"), + "openrouter": Provider(None, "api_key", "api_base", "OpenRouter"), + "deepseek": Provider(None, "api_key", "base_url", "DeepSeek"), +} + + +def _provider_option(names, note=""): + return click.option( + "-p", + "--provider", + type=click.Choice(names, case_sensitive=False), + metavar="PROVIDER", + help="LLM provider: " + + ", ".join(names) + + "." + + (" " + note + "." if note else ""), + ) + + +provider_option = _provider_option(list(PROVIDERS)) +configure_provider_option = _provider_option( + [name for name, spec in PROVIDERS.items() if spec.family] +) +run_provider_option = _provider_option( + [*PROVIDERS, "TypeSafe"], note="TypeSafe for the Jev decision model" +) + + +def llm_url_kwargs(runtime, url): + if url is None: + return {} + spec = next((spec for spec in PROVIDERS.values() if spec.runtime == runtime), None) + if spec is None: + # Only these loaders normalise the generic base_url keyword. + if runtime in {"OpenAILike", "DeepSeek", "MiniMax", "ZAI"}: + return {"base_url": url} + raise click.UsageError(f"-u/--llm-url is not supported for provider {runtime}") + if runtime == "XAI": + raise click.UsageError("-u/--llm-url is not supported for provider XAI") + return { + spec.url_keyword: ( + {"base_url": url} if spec.url_keyword == "http_options" else url + ) + } + + +def llm_retry_kwargs(runtime): + """Allow one SDK retry to honour short Retry-After delays. + + The agent's separate retry loop does not yet honour Retry-After. + """ + from mobilerun.agent.providers.registry import list_provider_families + from mobilerun.agent.utils.llm_picker import ( + SUPPORTED_PROVIDERS, + normalize_provider_name, + ) + + runtime = normalize_provider_name(runtime) + for family in list_provider_families(): + for variant in family.variants: + if runtime not in {variant.id, variant.runtime_provider_name}: + continue + # These adapters have no max_retries constructor field. + if family.id == "ollama" or ( + variant.auth_mode == "oauth" and family.id in {"anthropic", "gemini"} + ): + return {} + return {"max_retries": 1} + # Legacy providers outside the family registry still use standard adapters. + return {"max_retries": 1} if runtime in SUPPORTED_PROVIDERS else {} diff --git a/mobilerun/cli/qr.py b/mobilerun/cli/qr.py new file mode 100644 index 00000000..f3d592bd --- /dev/null +++ b/mobilerun/cli/qr.py @@ -0,0 +1,24 @@ +"""Shared terminal QR rendering.""" + +from __future__ import annotations + +import sys +from io import StringIO + + +def print_qr(data: str) -> bool: + """Print a compact QR with its quiet zone to terminal stderr.""" + import segno + + try: + if not sys.stderr.isatty(): + return False + output = StringIO() + segno.make_qr(data).terminal(out=output, compact=True) + for line in output.getvalue().splitlines(): + # Compact output uses full blocks for light modules and spaces + # for dark ones. Pin both colours independently of the theme. + sys.stderr.write(f"\x1b[97;40m{line}\x1b[0m\n") + except Exception: + return False + return True diff --git a/mobilerun/cli/run.py b/mobilerun/cli/run.py new file mode 100644 index 00000000..42cf212f --- /dev/null +++ b/mobilerun/cli/run.py @@ -0,0 +1,607 @@ +"""Run tasks with the framework agent and native async drivers.""" + +from __future__ import annotations + +import logging +from dataclasses import replace +from typing import TYPE_CHECKING + +import click +from async_adbutils import adb +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + +from mobilerun.agent.external import list_agents +from mobilerun.cli.options import ( + DEFAULT_API_URL, + Command, + coro, + device_options, + emit, + examples, + json_option, + llm_url_option, + max_steps_option, + tcp_option, +) +from mobilerun.cli.providers import ( + PROVIDERS, + llm_retry_kwargs, + llm_url_kwargs, + run_provider_option, +) +from mobilerun.cli.selection import _native_driver, select_device +from mobilerun.log_handlers import CLILogHandler, configure_logging + +console = Console(stderr=True) + + +if TYPE_CHECKING: + from mobilerun import ResultEvent + from mobilerun.config_manager import MobileConfig + from mobilerun.config_manager.config_manager import LLMProfile + + +def _force_screenshot_only_vision(config: MobileConfig) -> None: + config.agent.vision_only = True + config.agent.manager.vision = True + config.agent.executor.vision = True + config.agent.fast_agent.vision = True + + +def _setup_cli_logging(debug: bool) -> None: + """Configure the mobilerun logger with a CLILogHandler.""" + handler = CLILogHandler() + handler.console = console + handler.setFormatter( + logging.Formatter("%(levelname)s %(name)s %(message)s", "%H:%M:%S") + if debug + else logging.Formatter("%(message)s") + ) + configure_logging(debug=debug, handler=handler) + + +async def _run_task( + command: str, + config_path: str | None = None, + device: str | None = None, + agent: str | None = None, + provider: str | None = None, + model: str | None = None, + steps: int | None = None, + vision: bool | None = None, + vision_only: bool | None = None, + reasoning: bool | None = None, + stream: bool | None = None, + tracing: bool | None = None, + debug: bool | None = None, + tcp: bool | None = None, + control_backend: str | None = None, + device_id: str | None = None, + save_trajectory: str | None = None, + platform: str | None = None, + temperature: float | None = None, + llm_url: str | None = None, + target: str = "local", + api_url: str = DEFAULT_API_URL, + no_delegate: bool = False, + delegate_model: str | None = None, +) -> tuple[bool, str | None, int | None]: + """Execute a task and return the result-event fields.""" + from mobilerun_core_local.driver.visual_remote import VISUAL_REMOTE_CONNECTION + + from mobilerun.config_manager import ConfigLoader + + config = ConfigLoader.load(config_path) + if reasoning is not None: + config.agent.reasoning = reasoning + if delegate_model and (provider or "").strip().lower() != "typesafe": + delegation = config.agent.delegation + role = _action_role(config) + if ( + (delegation and delegation.primary) or role + ) not in config.decision_profiles: + raise click.UsageError( + "--delegate-model needs a decision model picking actions: pass " + "-p TypeSafe or set decision_profiles in the config." + ) + + from mobilerun import MobileAgent + from mobilerun.agent.selectors import LlamaIndexLLMSelector + from mobilerun.agent.utils.llm_picker import load_llm + from mobilerun.cli.event_handler import EventHandler + from mobilerun.config_manager.config_manager import DecisionProfile + from mobilerun.telemetry import print_telemetry_message + + # Print cloud link in a box + if config.logging.rich_text: + cloud_text = Text() + cloud_text.append("✨ Try Mobilerun Cloud: ", style="bold cyan") + cloud_text.append( + "https://cloud.mobilerun.ai/sign-in", style="bold blue underline" + ) + cloud_panel = Panel( + cloud_text, + border_style="cyan", + padding=(0, 1), + ) + console.print(cloud_panel) + else: + console.print("\n✨ Try Mobilerun Cloud: https://cloud.mobilerun.ai/sign-in\n") + + # Initialize logging + debug_mode = debug if debug is not None else config.logging.debug + _setup_cli_logging(debug_mode) + logger = logging.getLogger("mobilerun") + droid_agent = None + injected_driver = None + is_cloud = False + + try: + logger.info(f"🚀 Starting: {command}") + print_telemetry_message(config_enabled=config.telemetry.enabled) + + # ================================================================ + # STEP 1: Apply CLI overrides via direct mutation + # ================================================================ + + # Vision overrides + if vision_only is not None: + config.agent.vision_only = vision_only + + if config.agent.vision_only: + _force_screenshot_only_vision(config) + logger.debug("CLI override: vision_only=True") + elif vision is not None: + # --vision flag overrides all agents + config.agent.manager.vision = vision + config.agent.executor.vision = vision + config.agent.fast_agent.vision = vision + logger.debug(f"CLI override: vision={vision} (all agents)") + # Agent overrides + if agent is not None: + config.agent.name = agent + if steps is not None: + config.agent.max_steps = steps + if stream is not None: + config.agent.streaming = stream + + if no_delegate: + _drop_delegation(config) + + # A decision model picks actions in place of the action LLM. + if provider and provider.strip().lower() == "typesafe": + # Override the profile that will actually pick actions. + delegation = config.agent.delegation + role = ( + delegation.primary + if delegation is not None and delegation.primary + else _action_role(config) + ) + existing = config.decision_profiles.get(role) + endpoint = llm_url + configured = ( + existing is not None and existing.provider.lower() == "typesafe" + ) + if configured: + config.decision_profiles[role] = replace( + existing, + model=model or existing.model, + base_url=endpoint or existing.base_url, + ) + else: + config.decision_profiles[role] = DecisionProfile( + model=model, base_url=endpoint + ) + # Endpoints given with -p belong to that provider, not the helper LLMs. + provider = model = llm_url = None + # Delegate by default, unless the config already sets Jev up alone. + if not no_delegate and config.agent.delegation is None and not configured: + try: + _apply_delegate_model(config) + except ValueError as error: + logger.warning(f"{error} Jev will run alone.") + if delegate_model: + _apply_delegate_model(config, delegate_model) + if not (provider or model) and config.agent.name in {"mobilerun", "droidrun"}: + _log_delegate(config, logger) + + # Device overrides + if platform is not None: + # Resolve against the loaded platform before changing token ownership. + config.device.auth_token = config.device.resolve_auth_token(platform) + config.device.platform = platform + if device is not None: + config.device.serial = device + if tcp is not None: + config.device.use_tcp = tcp + if control_backend is not None: + config.device.control_backend = control_backend + if device_id is not None: + config.device.device_id = device_id + + if ( + config.device.control_backend or "" + ).strip().lower() == VISUAL_REMOTE_CONNECTION: + _force_screenshot_only_vision(config) + logger.debug("Control backend visual-remote forces screenshot-only vision") + + # Logging overrides + if debug is not None: + config.logging.debug = debug + if save_trajectory is not None: + config.logging.save_trajectory = save_trajectory + + # Tracing overrides + if tracing is not None: + config.tracing.enabled = tracing + + # The framework keeps its native async drivers. Core decides the backend. + if not config.device.control_backend: + selection = await select_device( + config.device.serial, + config_path, + tcp, + config.device.platform, + target, + discover=False, + config=config, + ) + config.device.serial = selection.device + config.device.platform = selection.platform + if selection.backend == "cloud": + is_cloud = True + if tcp: + raise click.UsageError( + "--tcp is only supported for local Android devices" + ) + injected_driver = await _native_driver(selection, config, api_url) + # CloudDriver supplies Android-shaped trees for every platform. + config.device.platform = "android" + config.device.serial = None + config.device.device_id = selection.device + config.device.use_tcp = False + config.device.auto_setup = False + + # ================================================================ + # STEP 2: Initialize MobileAgent with config + # ================================================================ + + mode = ( + "planning with reasoning" if config.agent.reasoning else "direct execution" + ) + logger.info(f"🤖 Agent mode: {mode}") + logger.info( + f"👁️ Vision settings: Manager={config.agent.manager.vision}, " + f"Executor={config.agent.executor.vision}, FastAgent={config.agent.fast_agent.vision}" + ) + + if config.tracing.enabled: + logger.info("🔍 Tracing enabled") + + # Build MobileAgent kwargs for LLM loading + droid_agent_kwargs = {"runtype": "cli"} + llm = None + + if provider or model: + if not (provider and model): + raise ValueError("--provider and --model must be given together") + llm_kwargs = llm_retry_kwargs(provider) + if temperature is not None: + llm_kwargs["temperature"] = temperature + llm_kwargs.update(llm_url_kwargs(provider, llm_url)) + llm = load_llm(provider, model=model, **llm_kwargs) + else: + for profile in config.llm_profiles.values(): + for key, value in llm_retry_kwargs(profile.provider).items(): + profile.kwargs.setdefault(key, value) + if llm_url is not None: + for profile in config.llm_profiles.values(): + profile.kwargs.update(llm_url_kwargs(profile.provider, llm_url)) + if temperature is not None: + droid_agent_kwargs["temperature"] = temperature + + droid_agent = MobileAgent( + goal=command, + action_selectors=LlamaIndexLLMSelector(llm) if llm is not None else None, + config=config, + driver=injected_driver, + timeout=1000, + **droid_agent_kwargs, + ) + + # ================================================================ + # STEP 3: Run agent + # ================================================================ + + logger.debug("▶️ Starting agent execution...") + logger.debug("Press Ctrl+C to stop") + + event_handler = EventHandler() + + handler = droid_agent.run() + async for event in handler.stream_events(): + event_handler.handle(event) + result: ResultEvent = await handler + return result.success, result.reason, result.steps + + finally: + if not is_cloud: + await _cleanup_android_keyboard( + config, getattr(droid_agent, "android_serial", None) + ) + + +async def run_command(*args, **kwargs) -> bool: + """Keep the boolean interface used by macro handoff and Python callers.""" + if kwargs.get("no_delegate") and kwargs.get("delegate_model"): + return False + try: + success, _, _ = await _run_task(*args, **kwargs) + return success + except Exception: + return False + + +def _action_role(config: MobileConfig) -> str: + return "executor" if config.agent.reasoning else "fast_agent" + + +def _drop_delegation(config: MobileConfig) -> None: + """Turn delegation off, keeping a named decision primary as the action picker.""" + delegation = config.agent.delegation + role = _action_role(config) + if ( + delegation is not None + and delegation.primary + and delegation.primary != role + and delegation.primary in config.decision_profiles + ): + config.decision_profiles[role] = config.decision_profiles[delegation.primary] + elif ( + delegation is not None + and delegation.primary + and delegation.primary != role + and delegation.primary in config.llm_profiles + ): + config.decision_profiles.pop(role, None) + config.llm_profiles[role] = config.llm_profiles[delegation.primary] + config.agent.delegation = None + + +def _apply_delegate_model(config: MobileConfig, spec: str | None = None) -> None: + from mobilerun.cli.configure_wizard import ensure_llm_delegation + + role = _action_role(config) + profile = _delegate_profile(config, role, spec) if spec else None + ensure_llm_delegation(config, role, profile) + + +# These providers take vendor-prefixed model ids such as "anthropic/claude". +_VENDOR_MODEL_PROVIDERS = {"openrouter", "openailike"} + + +def _delegate_profile(config: MobileConfig, role: str, spec: str) -> LLMProfile: + """Profile for --delegate-model PROVIDER/MODEL, or MODEL on the agent's provider.""" + from mobilerun.agent.providers.setup_service import profile_for_provider + from mobilerun.agent.utils.llm_picker import normalize_provider_name + + spec = spec.strip() + provider, _, model = spec.partition("/") + requested_provider = provider + if provider.lower() in PROVIDERS: + provider = PROVIDERS[provider.lower()].runtime + base = config.llm_profiles.get(role) or config.llm_profiles.get("fast_agent") + base_name = normalize_provider_name(base.provider).lower() if base else "" + name = normalize_provider_name(provider).lower() + family = (getattr(base, "provider_family", None) or "").lower() + if base_name in _VENDOR_MODEL_PROVIDERS and family in {"", "openai_like"}: + # Preserve vendor/model ids while accepting exact names and CLI aliases. + is_provider = requested_provider in _known_providers(exact=True) or ( + requested_provider.lower() in PROVIDERS + and requested_provider.lower() != provider.lower() + ) + else: + is_provider = name in _known_providers() + if model and is_provider: + if name in {base_name, family}: + return replace(base, model=model) + return profile_for_provider(provider, model) + if base is None: + raise click.UsageError( + "--delegate-model needs PROVIDER/MODEL when the config has no agent LLM." + ) + return replace(base, model=spec) + + +def _known_providers(exact: bool = False) -> set[str]: + from mobilerun.agent.providers import list_provider_families + from mobilerun.agent.utils.llm_picker import SUPPORTED_PROVIDERS + + names = set(SUPPORTED_PROVIDERS) + for family in list_provider_families(): + names.update(v.runtime_provider_name for v in family.variants) + return names if exact else {name.lower() for name in names} + + +def _log_delegate(config: MobileConfig, logger: logging.Logger) -> None: + delegation = config.agent.delegation + if delegation is None: + return + profile = config.llm_profiles.get(delegation.delegate) + if profile is not None: + logger.info(f"🤝 Delegate: {profile.provider}/{profile.model}") + + +async def _cleanup_android_keyboard( + config: MobileConfig, serial: str | None = None +) -> None: + from mobilerun_core_local.driver.visual_remote import VISUAL_REMOTE_CONNECTION + + from mobilerun.agent.utils.android_device import is_portal_http_url + + platform = (config.device.platform or "").lower() + control_backend = (config.device.control_backend or "").lower() + if platform == "ios" or control_backend == VISUAL_REMOTE_CONNECTION: + return + + # Without a selected device the keyboard was never enabled. + serial = serial or config.device.serial + if not serial or is_portal_http_url(serial): + return + + try: + device_obj = await adb.device(serial) + if device_obj: + from mobilerun_core_local.driver.android.portal import ( + PORTAL_PACKAGE_NAME, + portal_ime_id, + ) + + ime = portal_ime_id(PORTAL_PACKAGE_NAME) + await device_obj.shell(f"ime disable {ime}") + except Exception: + click.echo("Failed to disable Mobilerun keyboard", err=True) + + +class AgentOption(click.Option): + def installed_type(self): + self.type = click.Choice(list_agents()) + return self.type + + def get_help_record(self, ctx): + if not self.installed_type().choices: + return None + return super().get_help_record(ctx) + + def type_cast_value(self, ctx, value): + if value is not None and not self.installed_type().choices: + raise click.BadParameter( + "No external agents are installed; install one before using --agent.", + ctx=ctx, + param=self, + ) + return super().type_cast_value(ctx, value) + + def shell_complete(self, ctx, incomplete): + return self.installed_type().shell_complete(ctx, self, incomplete) + + +@click.command( + cls=Command, + epilog=examples( + 'mobilerun run "Open Settings"', + 'mobilerun run "Open Settings" -d DEVICE -p openai -m MODEL', + ), +) +@click.argument("task") +@device_options +@tcp_option +@json_option +@run_provider_option +@click.option("-m", "--model", help="LLM model (optional for TypeSafe).") +@click.option( + "--no-delegate", + is_flag=True, + default=False, + help="Let the decision model pick every action, without handing steps to an LLM.", +) +@click.option( + "--delegate-model", + default=None, + help=( + "LLM for the decision model's typing and unsure steps, as PROVIDER/MODEL " + "or MODEL (default: the agent's LLM)." + ), +) +@llm_url_option +@max_steps_option +@click.option("-T", "--temperature", type=float, help="LLM sampling temperature.") +@click.option("-a", "--agent", cls=AgentOption, help="External agent.") +@click.option( + "-r", "--reasoning/--no-reasoning", default=None, help="Enable agent reasoning." +) +@click.option( + "--vision/--no-vision", default=None, help="Enable vision for all agents." +) +@click.option( + "--vision-only", + is_flag=True, + default=None, + help="Use screenshots without the UI tree.", +) +@click.option("--stream/--no-stream", default=None, help="Stream agent responses.") +@click.option("--tracing/--no-tracing", default=None, help="Enable tracing.") +@click.option( + "--backend", type=click.Choice(["visual-remote"]), help="Device control backend." +) +@click.option("--backend-device", help="Device id for a visual remote backend.") +@click.option( + "--save-trajectory", + type=click.Choice(["none", "step", "action"]), + help="Choose which trajectory data to save.", +) +@coro +async def run( + task, + config_path, + device, + target, + platform, + api_url, + tcp, + verbose, + json_output, + provider, + model, + llm_url, + max_steps, + temperature, + agent, + reasoning, + vision, + vision_only, + stream, + tracing, + backend, + backend_device, + save_trajectory, + no_delegate, + delegate_model, +): + """Run a task on a mobile device using natural language.""" + if provider != "TypeSafe" and (provider is None) != (model is None): + raise click.UsageError("--provider and --model must be given together") + if no_delegate and delegate_model: + raise click.UsageError("--delegate-model cannot be used with --no-delegate.") + success, reason, steps = await _run_task( + command=task, + config_path=config_path, + device=device, + target=target, + platform=platform, + api_url=api_url, + tcp=tcp, + debug=verbose, + provider=PROVIDERS[provider].runtime if provider in PROVIDERS else provider, + model=model, + llm_url=llm_url, + steps=max_steps, + temperature=temperature, + agent=agent, + reasoning=reasoning, + vision=vision, + vision_only=vision_only, + stream=stream, + tracing=tracing, + control_backend=backend, + device_id=backend_device, + save_trajectory=save_trajectory, + no_delegate=no_delegate, + delegate_model=delegate_model, + ) + if json_output: + emit({"success": success, "reason": reason, "steps": steps}, json_output=True) + if not success: + raise click.ClickException(reason or "Task failed") diff --git a/mobilerun/cli/selection.py b/mobilerun/cli/selection.py new file mode 100644 index 00000000..5cadef65 --- /dev/null +++ b/mobilerun/cli/selection.py @@ -0,0 +1,255 @@ +"""Resolve CLI config and device selection before invoking a backend.""" + +from __future__ import annotations + +import asyncio +import os +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import click + +from mobilerun.cli.options import ADB_HINT, DEFAULT_API_URL + +if TYPE_CHECKING: + from mobilerun.config_manager import MobileConfig + + +@contextmanager +def local_adb_errors(enabled=True): + """Translate local ADB startup failures after the library tries to start it.""" + from async_adbutils import AdbError, AdbTimeout + + try: + yield + except ( + AdbError, + asyncio.TimeoutError, + ConnectionRefusedError, + FileNotFoundError, + ModuleNotFoundError, + ) as exc: + message = str(exc).lower() + missing_binary = ( + isinstance(exc, FileNotFoundError) + and exc.filename + and ( + str(exc.filename).rsplit("/", 1)[-1] in {"adb", "adb.exe"} + or exc.filename == os.environ.get("ADBUTILS_ADB_PATH") + ) + ) + unavailable = ( + isinstance(exc, (ConnectionRefusedError, asyncio.TimeoutError, AdbTimeout)) + or missing_binary + or ( + isinstance(exc, ModuleNotFoundError) + and exc.name in {"adbutils", "adbutils.binaries"} + ) + or ( + isinstance(exc, AdbError) + and any( + text in message + for text in ( + "no adb exe", + "cannot connect", + "can't connect", + "connection refused", + ) + ) + ) + ) + if enabled and unavailable: + raise click.ClickException(ADB_HINT) from None + raise + + +CLOUD_CREDENTIAL_LABELS = {"env": "env key", "login": "saved sign-in", None: "not set"} + + +@dataclass(frozen=True) +class CloudCredential: + value: str | None = field(repr=False) + source: str | None + saved_sign_in: bool + env_var: str | None = None + + +def resolve_cloud_credential(): + """Resolve the cloud bearer and its source without changing precedence.""" + import os + + from mobilerun_core.connection.cloud import ( + BEARER_ENV_VAR, + LEGACY_BEARER_ENV_VAR, + bearer_token_from_env, + ) + + from mobilerun.cli import deviceauth + + credentials = deviceauth.load_credentials() or {} + saved = credentials.get("api_key") or credentials.get("access_token") + env_key = bearer_token_from_env() + if env_key: + return CloudCredential( + env_key, + "env", + bool(saved), + BEARER_ENV_VAR if os.environ.get(BEARER_ENV_VAR) else LEGACY_BEARER_ENV_VAR, + ) + return CloudCredential(saved, "login" if saved else None, bool(saved)) + + +def resolve_cloud_api_key(): + return resolve_cloud_credential().value + + +def core_client(api_url=DEFAULT_API_URL, *, cloud=False): + import mobilerun_core + + return mobilerun_core.Mobilerun( + bearer=require_cloud_api_key() if cloud else resolve_cloud_api_key(), + base_url=api_url, + ) + + +@dataclass +class Selection: + config: MobileConfig + device: str | None + platform: str + backend: str + + +async def select_device( + device=None, + config_path=None, + tcp=None, + platform=None, + target="local", + *, + discover=True, + config=None, +): + from mobilerun_core.detect import is_uuid, resolve_backend + from mobilerun_core_local.driver.ios import ( + discover_ios_device, + validate_ios_portal_url, + ) + + from mobilerun.agent.utils.android_device import ( + AndroidDeviceSelectionError, + is_portal_http_url, + resolve_android_serial, + ) + from mobilerun.config_manager import ConfigLoader + + config = config if config is not None else ConfigLoader.load(config_path) + selected = device if device is not None else config.device.serial + selected_platform = platform or config.device.platform + if tcp is not None: + config.device.use_tcp = tcp + if not selected: + if target == "cloud": + raise click.ClickException( + "Cloud device required: pass -d ; find ids with `mobilerun device list -t cloud`." + ) + if not discover and selected_platform != "ios": + return Selection(config, None, selected_platform, "local-android-adb") + if selected_platform == "ios": + selected = await discover_ios_device() + else: + try: + with local_adb_errors(): + selected = await resolve_android_serial() + except AndroidDeviceSelectionError as exc: + raise click.ClickException(str(exc)) from exc + url = selected if is_portal_http_url(selected) else None + if selected_platform == "ios" and not is_uuid(selected): + url = validate_ios_portal_url(selected) + selected = url + backend = resolve_backend( + selected, + platform=selected_platform, + url=url, + backend="local-android-adb" if not url and not is_uuid(selected) else None, + ) + return Selection(config, selected, selected_platform, backend) + + +def require_cloud_api_key(): + key = resolve_cloud_api_key() + if not key: + raise click.ClickException( + "No cloud API key found. Set MOBILERUN_API_KEY or run `mobilerun auth login`." + ) + return key + + +def portal_token(selection): + from mobilerun.config_manager.config_manager import PORTAL_TOKEN_ENV_VARS + + token = selection.config.device.resolve_auth_token(selection.platform) + if selection.backend == "local-android-http" and not token: + raise click.ClickException( + f"Portal URL requires {PORTAL_TOKEN_ENV_VARS[selection.platform]} or device.auth_token in config" + ) + return token + + +async def _native_driver(selection, config, api_url): + """Connect the framework driver used by agents and macro replay.""" + from async_adbutils import adb + from mobilerun_core_local.driver.android import AndroidDriver + from mobilerun_core_local.driver.android.portal import ensure_portal_ready + from mobilerun_core_local.driver.cloud import CloudDriver + from mobilerun_core_local.driver.ios import create_ios_driver + + from mobilerun.agent.utils.android_device import create_android_portal_http_driver + from mobilerun.agent.utils.portal_setup import portal_version_kwargs + + with local_adb_errors(selection.backend == "local-android-adb"): + if selection.backend == "cloud": + driver = CloudDriver( + device_id=selection.device, + api_key=require_cloud_api_key(), + base_url=api_url, + ) + elif selection.backend == "local-ios-http": + driver = await create_ios_driver( + selection.device, token=portal_token(selection) + ) + elif selection.backend == "local-android-http": + driver = create_android_portal_http_driver( + selection.device, portal_token(selection) + ) + else: + if config.device.auto_setup and config.device.portal_mode != "disabled": + device = await adb.device(serial=selection.device) + await ensure_portal_ready( + device, debug=False, **portal_version_kwargs(ensure_portal_ready) + ) + driver = AndroidDriver( + serial=selection.device, + use_tcp=config.device.use_tcp, + portal_mode=config.device.portal_mode, + ) + await driver.connect() + return driver + + +async def teardown_native_driver(driver): + """Disable the framework keyboard after macro replay on local Android.""" + from mobilerun_core_local.driver.android import AndroidDriver + from mobilerun_core_local.driver.android.portal import ( + PORTAL_PACKAGE_NAME, + portal_ime_id, + ) + + if isinstance(driver, AndroidDriver) and driver.device: + try: + await driver.device.shell( + f"ime disable {portal_ime_id(PORTAL_PACKAGE_NAME)}" + ) + except Exception: + pass diff --git a/mobilerun/cli/setup.py b/mobilerun/cli/setup.py new file mode 100644 index 00000000..2d43aef5 --- /dev/null +++ b/mobilerun/cli/setup.py @@ -0,0 +1,182 @@ +""" +Mobilerun CLI - Command line interface for controlling Android devices through LLM agents. +""" + +import asyncio +import os +from contextlib import nullcontext + +from async_adbutils import adb +from mobilerun_core_local.driver.android.portal import ( + DOWNLOAD_BASE, + PORTAL_PACKAGE_NAME, + check_portal_accessibility, + download_portal_apk, + download_versioned_portal_apk, + enable_portal_accessibility, + portal_content_uri, + setup_portal, +) +from rich.console import Console + +from mobilerun.agent.utils.android_device import ( + AndroidDeviceSelectionError, + resolve_android_serial, +) +from mobilerun.agent.utils.portal_setup import portal_version_kwargs + +console = Console(stderr=True) + + +async def _wait_for_portal_service( + device_obj, timeout: float = 30.0, interval: float = 1.0 +) -> bool: + """Wait until Portal's accessibility service responds. + + Android drops the service when it is enabled before a fresh install is + registered, so it is enabled again while waiting. ``/ping`` answers once + the service is bound, even without an active window. A busy device can + take well over 10s to bind it. + """ + uri = portal_content_uri(PORTAL_PACKAGE_NAME, "ping") + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + try: + result = await device_obj.shell(f"content query --uri {uri}") + if '"status":"success"' in result: + return True + if not await check_portal_accessibility(device_obj): + await enable_portal_accessibility(device_obj) + except Exception: + pass + await asyncio.sleep(interval) + return False + + +async def _setup_portal( + path: str | None, + device: str | None, + debug: bool, + latest: bool = False, + specific_version: str | None = None, + in_place: bool = False, +): + """Internal async function to install and enable the Mobilerun Portal on a device.""" + try: + if not device: + try: + device = await resolve_android_serial() + except AndroidDeviceSelectionError as e: + console.print(f"[bold red]Error:[/] {e}") + return False + console.print(f"[blue]Using device:[/] {device}") + + device_obj = await adb.device(device) + if not device_obj: + console.print( + f"[bold red]Error:[/] Could not get device object for {device}" + ) + return False + + # CLI-specific options: path, specific_version, latest + if path: + console.print(f"[bold blue]Using provided APK:[/] {path}") + apk_context = nullcontext(path) + elif specific_version: + version = specific_version.lstrip("v") + version = f"v{version}" + download_base = DOWNLOAD_BASE + apk_context = download_versioned_portal_apk(version, download_base, debug) + elif latest: + console.print("[bold blue]Downloading latest Portal APK...[/]") + apk_context = download_portal_apk(debug) + else: + # Default: delegate to shared setup_portal() + success = await setup_portal( + device_obj, + debug, + **portal_version_kwargs(setup_portal, in_place=in_place), + ) + if success: + console.print( + "\n[bold green]Setup complete![/] The Mobilerun Portal is now installed and ready to use." + ) + else: + console.print( + "[bold red]Setup failed.[/] Run 'mobilerun doctor' for diagnostics." + ) + return bool(success) + + # Install from explicit path/version/latest + with apk_context as apk_path: + if not os.path.exists(apk_path): + console.print(f"[bold red]Error:[/] APK file not found at {apk_path}") + return False + + console.print(f"[bold blue]Step 1/2: Installing APK:[/] {apk_path}") + try: + await device_obj.install( + apk_path, + nolaunch=True, + uninstall=True, + flags=["-g"], + silent=not debug, + ) + except Exception as e: + console.print(f"[bold red]Installation failed:[/] {e}") + return False + + console.print("[bold green]Installation successful![/]") + + console.print("[bold blue]Step 2/2: Enabling accessibility service[/]") + + try: + await enable_portal_accessibility(device_obj) + if not await _wait_for_portal_service(device_obj): + raise RuntimeError("the service did not respond in time") + + console.print("[green]Accessibility service enabled successfully![/]") + console.print( + "\n[bold green]Setup complete![/] The Mobilerun Portal is now installed and ready to use." + ) + + except Exception as e: + console.print( + f"[yellow]Could not automatically enable accessibility service: {e}[/]" + ) + console.print( + "[yellow]Opening accessibility settings for manual configuration...[/]" + ) + + await device_obj.shell( + "am start -a android.settings.ACCESSIBILITY_SETTINGS" + ) + + console.print( + "\n[yellow]Please complete the following steps on your device:[/]" + ) + console.print( + f"1. Find [bold]{PORTAL_PACKAGE_NAME}[/] in the accessibility services list" + ) + console.print("2. Tap on the service name") + console.print( + "3. Toggle the switch to [bold]ON[/] to enable the service" + ) + console.print("4. Accept any permission dialogs that appear") + + console.print( + "\n[bold green]APK installation complete![/] Please manually enable the accessibility service using the steps above." + ) + return False + return True + + except Exception as e: + console.print(f"[bold red]Error:[/] {e}") + + if debug: + import traceback + + traceback.print_exc() + + return False diff --git a/mobilerun/cli/tree.py b/mobilerun/cli/tree.py new file mode 100644 index 00000000..ef1b79b5 --- /dev/null +++ b/mobilerun/cli/tree.py @@ -0,0 +1,59 @@ +"""Mobilerun CLI entry point.""" + +import os +import warnings +from importlib.metadata import version + +import click + +from mobilerun.cli.options import Group, examples, verbose_option +from mobilerun.cli.run import run, run_command + +__all__ = ["cli", "run_command"] + + +@click.group( + cls=Group, + command_order=[ + "run", + "device", + "configure", + "auth", + "doctor", + "env", + "macro", + "completion", + ], + epilog=examples( + "mobilerun device list", + "mobilerun configure -p openai", + 'mobilerun run "Open Settings"', + docs="https://docs.mobilerun.ai/framework/guides/cli", + ), +) +@click.version_option(version("mobilerun"), "-V", "--version") +@verbose_option +def cli(verbose): + """Mobilerun — control mobile devices and run LLM tasks.""" + warnings.filterwarnings("ignore") + os.environ["TOKENIZERS_PARALLELISM"] = "false" + os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" + + +from mobilerun.cli.auth import auth_cli # noqa: E402 +from mobilerun.cli.completion import completion # noqa: E402 +from mobilerun.cli.configure import configure # noqa: E402 +from mobilerun.cli.device_lifecycle import device_cli # noqa: E402 +from mobilerun.cli.doctor_command import doctor # noqa: E402 +from mobilerun.cli.env import env # noqa: E402 +from mobilerun.macro.cli import macro_cli # noqa: E402 + +cli.add_command(run) +cli.add_command(device_cli, "device") +cli.add_command(auth_cli, "auth") +cli.add_command(configure) +cli.add_command(macro_cli, "macro") +cli.add_command(doctor) +cli.add_command(env) + +cli.add_command(completion) diff --git a/mobilerun/config_example.yaml b/mobilerun/config_example.yaml index 94d931dd..bc7b86c3 100644 --- a/mobilerun/config_example.yaml +++ b/mobilerun/config_example.yaml @@ -153,7 +153,7 @@ device: # Bearer token for portal-HTTP device URLs: the token shown in the Android # Portal app (always required), or a `mobilerun-ios --local` server's # --local-token (loopback servers need none). - # The MOBILERUN_DEVICE_TOKEN env var overrides this value. + # MOBILERUN_ANDROID_PORTAL_TOKEN or MOBILERUN_IOS_PORTAL_TOKEN overrides this value for its platform. auth_token: null # === Telemetry Settings === diff --git a/mobilerun/config_manager/__init__.py b/mobilerun/config_manager/__init__.py index 232d91e9..cd01de6e 100644 --- a/mobilerun/config_manager/__init__.py +++ b/mobilerun/config_manager/__init__.py @@ -1,25 +1,56 @@ -from mobilerun.config_manager.config_manager import ( - AgentConfig, - AppCardConfig, - CredentialsConfig, - DecisionConfig, - DecisionProfile, - DelegationConfig, - DeviceConfig, - DroidConfig, # Legacy alias - ExecutorConfig, - FastAgentConfig, - LLMProfile, - LoggingConfig, - ManagerConfig, - MobileConfig, - TelemetryConfig, - ToolsConfig, - TracingConfig, -) -from mobilerun.config_manager.loader import ConfigLoader, OutdatedConfigError -from mobilerun.config_manager.path_resolver import PathResolver -from mobilerun.config_manager.prompt_loader import PromptLoader +from importlib import import_module +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mobilerun.config_manager.config_manager import ( + AgentConfig, + AppCardConfig, + CredentialsConfig, + DecisionConfig, + DecisionProfile, + DelegationConfig, + DeviceConfig, + DroidConfig, + ExecutorConfig, + FastAgentConfig, + LLMProfile, + LoggingConfig, + ManagerConfig, + MobileConfig, + TelemetryConfig, + ToolsConfig, + TracingConfig, + ) + from mobilerun.config_manager.loader import ConfigLoader, OutdatedConfigError + from mobilerun.config_manager.path_resolver import PathResolver + from mobilerun.config_manager.prompt_loader import PromptLoader + +_EXPORTS = { + "AgentConfig": ("mobilerun.config_manager.config_manager", "AgentConfig"), + "AppCardConfig": ("mobilerun.config_manager.config_manager", "AppCardConfig"), + "CredentialsConfig": ( + "mobilerun.config_manager.config_manager", + "CredentialsConfig", + ), + "DecisionConfig": ("mobilerun.config_manager.config_manager", "DecisionConfig"), + "DecisionProfile": ("mobilerun.config_manager.config_manager", "DecisionProfile"), + "DelegationConfig": ("mobilerun.config_manager.config_manager", "DelegationConfig"), + "DeviceConfig": ("mobilerun.config_manager.config_manager", "DeviceConfig"), + "DroidConfig": ("mobilerun.config_manager.config_manager", "DroidConfig"), + "ExecutorConfig": ("mobilerun.config_manager.config_manager", "ExecutorConfig"), + "FastAgentConfig": ("mobilerun.config_manager.config_manager", "FastAgentConfig"), + "LLMProfile": ("mobilerun.config_manager.config_manager", "LLMProfile"), + "LoggingConfig": ("mobilerun.config_manager.config_manager", "LoggingConfig"), + "ManagerConfig": ("mobilerun.config_manager.config_manager", "ManagerConfig"), + "MobileConfig": ("mobilerun.config_manager.config_manager", "MobileConfig"), + "TelemetryConfig": ("mobilerun.config_manager.config_manager", "TelemetryConfig"), + "ToolsConfig": ("mobilerun.config_manager.config_manager", "ToolsConfig"), + "TracingConfig": ("mobilerun.config_manager.config_manager", "TracingConfig"), + "ConfigLoader": ("mobilerun.config_manager.loader", "ConfigLoader"), + "OutdatedConfigError": ("mobilerun.config_manager.loader", "OutdatedConfigError"), + "PathResolver": ("mobilerun.config_manager.path_resolver", "PathResolver"), + "PromptLoader": ("mobilerun.config_manager.prompt_loader", "PromptLoader"), +} __all__ = [ "MobileConfig", @@ -44,3 +75,16 @@ "PathResolver", "PromptLoader", ] + + +def __getattr__(name): + if name in _EXPORTS: + module, attribute = _EXPORTS[name] + value = getattr(import_module(module), attribute) + globals()[name] = value + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return sorted(set(globals()) | set(__all__)) diff --git a/mobilerun/config_manager/config_manager.py b/mobilerun/config_manager/config_manager.py index 33283a60..e6dc9791 100644 --- a/mobilerun/config_manager/config_manager.py +++ b/mobilerun/config_manager/config_manager.py @@ -5,6 +5,10 @@ from typing import Any, Dict, List, Literal, Optional import yaml +from mobilerun_core.mobilerun import ( + ANDROID_PORTAL_TOKEN_ENV_VAR, + IOS_PORTAL_TOKEN_ENV_VAR, +) from mobilerun.agent.providers.minimax import warn_if_legacy_minimax_endpoint from mobilerun.agent.providers.registry import ( @@ -19,6 +23,12 @@ DEFAULT_STATELESS_MANAGER_SYSTEM_PROMPT = "config/prompts/manager/stateless.jinja2" +PORTAL_TOKEN_ENV_VARS = { + "android": ANDROID_PORTAL_TOKEN_ENV_VAR, + "ios": IOS_PORTAL_TOKEN_ENV_VAR, +} + + # ---------- Config Schema ---------- @dataclass class LLMProfile: @@ -236,12 +246,22 @@ class DeviceConfig: auto_setup: bool = True # auto-install/fix portal before each run # Bearer token for portal-HTTP device URLs: the Android Portal app's token # (always required) or a mobilerun-ios --local server's --local-token - # (loopback servers need none). The MOBILERUN_DEVICE_TOKEN env var - # overrides this value. + # (loopback servers need none). The platform's MOBILERUN_ANDROID_PORTAL_TOKEN + # or MOBILERUN_IOS_PORTAL_TOKEN env var overrides this value. auth_token: Optional[str] = None - def resolve_auth_token(self) -> Optional[str]: - return os.environ.get("MOBILERUN_DEVICE_TOKEN") or self.auth_token + def resolve_auth_token(self, platform: str | None = None) -> Optional[str]: + """Use the requested platform's env token, or its own config token.""" + requested = self.platform if platform is None else platform + name = PORTAL_TOKEN_ENV_VARS.get((requested or "").lower()) + if name is None: + return None + configured = ( + self.auth_token + if requested.lower() == (self.platform or "").lower() + else None + ) + return os.environ.get(name) or configured @dataclass diff --git a/mobilerun/config_manager/loader.py b/mobilerun/config_manager/loader.py index ea9c1ba2..bd8b581a 100644 --- a/mobilerun/config_manager/loader.py +++ b/mobilerun/config_manager/loader.py @@ -44,30 +44,20 @@ def load(cls, config_path: Optional[str] = None) -> MobileConfig: ~/.config/droidrun/config.yaml on Linux) 4. Package defaults (creates user config) """ - if config_path: - return cls._load_user_config(Path(config_path)) - - env_config = os.environ.get("MOBILERUN_CONFIG") or os.environ.get( - "DROIDRUN_CONFIG" - ) - if os.environ.get("DROIDRUN_CONFIG") and not os.environ.get("MOBILERUN_CONFIG"): - import warnings - - warnings.warn( - "DROIDRUN_CONFIG is deprecated. Use MOBILERUN_CONFIG instead.", - DeprecationWarning, - stacklevel=2, - ) - if env_config and Path(env_config).exists(): - return cls._load_user_config(Path(env_config)) - - user_config_path = cls.get_user_config_path() - - if user_config_path.exists(): - return cls._load_user_config(user_config_path) - + path = cls.resolve_path(config_path) + if path.exists(): + return cls._load_user_config(path) return cls._init_user_config() + @classmethod + def resolve_path(cls, explicit=None) -> Path: + """Resolve the config path, rejecting a missing explicitly selected file.""" + selected = explicit or os.environ.get("MOBILERUN_CONFIG") + path = Path(selected).expanduser() if selected else cls.get_user_config_path() + if selected and not path.is_file(): + raise FileNotFoundError(f"Config file does not exist: {path}") + return path + @classmethod def _load_user_config(cls, user_config_path: Path) -> MobileConfig: """Load user config and run migrations.""" @@ -97,11 +87,13 @@ def _init_user_config(cls) -> MobileConfig: return config @classmethod - def save(cls, config: MobileConfig) -> Path: + def save(cls, config: MobileConfig, path: str | Path | None = None) -> Path: """Save config to user config path.""" config_dict = config.to_dict() config_dict["_version"] = CURRENT_VERSION - return cls._save_dict(config_dict, cls.get_user_config_path()) + return cls._save_dict( + config_dict, Path(path) if path is not None else cls.get_user_config_path() + ) @classmethod def _save_dict(cls, config_dict: Dict[str, Any], path: Path) -> Path: diff --git a/mobilerun/macro/cli.py b/mobilerun/macro/cli.py index a5ab5776..2168422b 100644 --- a/mobilerun/macro/cli.py +++ b/mobilerun/macro/cli.py @@ -2,23 +2,35 @@ Command-line interface for Mobilerun macro replay. """ -import asyncio +from __future__ import annotations + import logging from typing import Optional import click from rich.console import Console -from rich.table import Table -from mobilerun.agent.utils.android_device import ( - AndroidDeviceSelectionError, - resolve_android_serial, +from mobilerun.cli.options import ( + Group, + config_option, + coro, + device_options, + emit, + emit_rows, + examples, + json_option, + max_steps_option, + model_option, + tcp_option, + verbose_option, +) +from mobilerun.cli.providers import PROVIDERS, provider_option +from mobilerun.cli.selection import ( + _native_driver, + select_device, + teardown_native_driver, ) -from mobilerun.agent.utils.trajectory import Trajectory from mobilerun.config_manager.path_resolver import PathResolver -from mobilerun.macro.replay import MacroPlayer - -console = Console() def configure_logging(debug: bool = False): @@ -43,27 +55,35 @@ def configure_logging(debug: bool = False): return logger -@click.group() -def macro_cli(): +@click.group(cls=Group, epilog=examples("mobilerun macro list ./macros")) +@verbose_option +def macro_cli(verbose): """Replay recorded automation sequences.""" pass -@macro_cli.command() -@click.argument("path", type=click.Path(exists=True)) -@click.option("--device", "-d", help="Device serial number", default=None) -@click.option( - "--delay", "-t", help="Delay between actions (seconds)", default=1.0, type=float +@macro_cli.command( + epilog=examples( + "mobilerun macro replay MACRO.json -n", + "mobilerun macro replay TRAJECTORY -d DEVICE", + ), ) +@click.argument("path", type=click.Path(exists=True)) +@device_options +@tcp_option @click.option( - "--start-from", "-s", help="Start from step number (1-based)", default=1, type=int + "--delay", help="Delay between actions in seconds.", default=1.0, type=float ) @click.option( - "--max-steps", "-m", help="Maximum steps to execute", default=None, type=int + "--start-from", help="Start from step number (1-based).", default=1, type=int ) -@click.option("--debug", is_flag=True, help="Enable debug logging", default=False) +@max_steps_option @click.option( - "--dry-run", is_flag=True, help="Show actions without executing", default=False + "-n", + "--dry-run", + is_flag=True, + help="Show actions without executing.", + default=False, ) @click.option( "--on-mismatch", @@ -86,20 +106,16 @@ def macro_cli(): type=float, help="Minimum normalized state similarity required before replaying an action.", ) -@click.option( - "--config", "config_path", default=None, help="Config file for agent handoff." -) -@click.option( - "--provider", default=None, help="LLM provider override for agent handoff." -) -@click.option("--model", default=None, help="LLM model override for agent handoff.") -def replay( +@provider_option +@model_option +@coro +async def replay( path: str, device: Optional[str], delay: float, start_from: int, max_steps: Optional[int], - debug: bool, + verbose: bool, dry_run: bool, on_mismatch: str, state_timeout: float, @@ -107,76 +123,31 @@ def replay( config_path: Optional[str], provider: Optional[str], model: Optional[str], + target, + platform, + api_url, + tcp, ): """Replay a macro from a file or trajectory folder.""" - logger = configure_logging(debug) + logger = configure_logging(bool(verbose)) + if provider: + provider = PROVIDERS[provider].runtime logger.info("🎬 Mobilerun Macro Replay") # Convert start_from from 1-based to 0-based start_from_zero = max(0, start_from - 1) - async def get_device(): - if device is None: - logger.info("🔍 Finding connected device...") - try: - dev = await resolve_android_serial() - except Exception as e: - # Dry runs never touch a device, so any discovery failure is fine. - if dry_run: - logger.warning(f"⚠️ {e}") - return None - if isinstance(e, AndroidDeviceSelectionError): - raise click.ClickException(str(e)) from None - raise - logger.info(f"📱 Using device: {dev}") - return dev - else: - logger.info(f"📱 Using device: {device}") - return device - - asyncio.run( - _replay_with_device( - path, - device, - delay, - start_from_zero, - max_steps, - dry_run, - logger, - get_device, - on_mismatch, - state_timeout, - state_threshold, - config_path, - provider, - model, - ) + selection = ( + None + if dry_run + else await select_device(device, config_path, tcp, platform, target) ) - - -async def _replay_with_device( - path: str, - device: str, - delay: float, - start_from: int, - max_steps: Optional[int], - dry_run: bool, - logger: logging.Logger, - get_device, - on_mismatch: str, - state_timeout: float, - state_threshold: float, - config_path: Optional[str], - provider: Optional[str], - model: Optional[str], -): - device = await get_device() await _replay_async( path, - device, + selection.device if selection else device, delay, - start_from, + start_from_zero, max_steps, dry_run, logger, @@ -186,6 +157,8 @@ async def _replay_with_device( config_path, provider, model, + selection=selection, + api_url=api_url, ) @@ -203,94 +176,94 @@ async def _replay_async( config_path: Optional[str], provider: Optional[str], model: Optional[str], + selection=None, + api_url=None, ): """Async function to handle macro replay.""" - try: - # Resolve path (checks working dir, then package dir) - resolved_path = PathResolver.resolve(path, must_exist=True) - - if resolved_path.is_file(): - logger.info(f"📄 Loading macro from file: {resolved_path}") - player = MacroPlayer( - device_serial=device, - delay_between_actions=delay, - on_mismatch=on_mismatch, - state_timeout=state_timeout, - state_threshold=state_threshold, - config_path=config_path, - provider=provider, - model=model, - ) - macro_data = player.load_macro_from_file(str(resolved_path)) - elif resolved_path.is_dir(): - logger.info(f"📁 Loading macro from folder: {resolved_path}") - player = MacroPlayer( - device_serial=device, - delay_between_actions=delay, - on_mismatch=on_mismatch, - state_timeout=state_timeout, - state_threshold=state_threshold, - config_path=config_path, - provider=provider, - model=model, - ) - macro_data = player.load_macro_from_folder(str(resolved_path)) - else: - logger.error(f"❌ Invalid path: {resolved_path}") - return + # Resolve path (checks working dir, then package dir) + from mobilerun.macro.replay import MacroPlayer + + resolved_path = PathResolver.resolve(path, must_exist=True) + + if resolved_path.is_file(): + logger.info(f"📄 Loading macro from file: {resolved_path}") + player = MacroPlayer( + device_serial=device, + delay_between_actions=delay, + on_mismatch=on_mismatch, + state_timeout=state_timeout, + state_threshold=state_threshold, + config_path=config_path, + provider=provider, + model=model, + ) + macro_data = player.load_macro_from_file(str(resolved_path)) + elif resolved_path.is_dir(): + logger.info(f"📁 Loading macro from folder: {resolved_path}") + player = MacroPlayer( + device_serial=device, + delay_between_actions=delay, + on_mismatch=on_mismatch, + state_timeout=state_timeout, + state_threshold=state_threshold, + config_path=config_path, + provider=provider, + model=model, + ) + macro_data = player.load_macro_from_folder(str(resolved_path)) + else: + raise click.ClickException(f"Invalid path: {resolved_path}") - if not macro_data: - logger.error("❌ Failed to load macro data") - return + if not macro_data: + raise click.ClickException("Failed to load macro data") - # Show macro information - description = macro_data.get("description", "No description") - total_actions = macro_data.get("total_actions", 0) - version = macro_data.get( - "macro_schema_version", macro_data.get("version", "unknown") - ) + # Show macro information + description = macro_data.get("description", "No description") + total_actions = macro_data.get("total_actions", 0) + version = macro_data.get( + "macro_schema_version", macro_data.get("version", "unknown") + ) - logger.info("📋 Macro Information:") - logger.info(f" Description: {description}") - logger.info(f" Version: {version}") - logger.info(f" Total actions: {total_actions}") - logger.info(f" Device: {device or 'not selected'}") - logger.info(f" Delay between actions: {delay}s") - logger.info(f" State threshold: {state_threshold}") - logger.info(f" State timeout: {state_timeout}s") - logger.info(f" On mismatch: {on_mismatch}") - - if start_from > 0: - logger.info(f" Starting from step: {start_from + 1}") - if max_steps: - logger.info(f" Maximum steps: {max_steps}") - - if dry_run: - logger.info("🔍 DRY RUN MODE - Actions will be shown but not executed") - await _show_dry_run(macro_data, start_from, max_steps, logger) - else: - logger.info("▶️ Starting macro replay...") + logger.info("📋 Macro Information:") + logger.info(f" Description: {description}") + logger.info(f" Version: {version}") + logger.info(f" Total actions: {total_actions}") + logger.info(f" Device: {device or 'not selected'}") + logger.info(f" Delay between actions: {delay}s") + logger.info(f" State threshold: {state_threshold}") + logger.info(f" State timeout: {state_timeout}s") + logger.info(f" On mismatch: {on_mismatch}") + + if start_from > 0: + logger.info(f" Starting from step: {start_from + 1}") + if max_steps: + logger.info(f" Maximum steps: {max_steps}") + + if dry_run: + logger.info("🔍 DRY RUN MODE - Actions will be shown but not executed") + await _show_dry_run(macro_data, start_from, max_steps, logger) + else: + logger.info("▶️ Starting macro replay...") + player.driver = await _native_driver(selection, selection.config, api_url) + try: success = await player.replay_macro( macro_data, start_from_step=start_from, max_steps=max_steps ) + finally: + await teardown_native_driver(player.driver) - if success: - logger.info("🎉 Macro replay completed successfully!") - else: - logger.error("💥 Macro replay completed with errors") - - except Exception as e: - logger.error(f"💥 Error: {e}") - if logger.isEnabledFor(logging.DEBUG): - import traceback - - logger.debug(traceback.format_exc()) + if success: + logger.info("🎉 Macro replay completed successfully!") + else: + raise click.ClickException("Macro replay failed") async def _show_dry_run( macro_data: dict, start_from: int, max_steps: Optional[int], logger: logging.Logger ): """Show what actions would be executed in dry run mode.""" + from rich.table import Table + actions = macro_data.get("actions", []) # Apply filters @@ -341,15 +314,21 @@ async def _show_dry_run( ) # Still use console for table display as it's structured data - console.print(table) + emit(console_render(table)) -@macro_cli.command() +@macro_cli.command(epilog=examples("mobilerun macro list ./macros")) @click.argument("directory", type=click.Path(exists=True), default="trajectories") -@click.option("--debug", is_flag=True, help="Enable debug logging", default=False) -def list(directory: str, debug: bool): +@config_option +@verbose_option +@json_option +def list(directory: str, config_path: Optional[str], verbose: bool, json_output: bool): """List available trajectory folders in a directory.""" - logger = configure_logging(debug) + from mobilerun.agent.utils.trajectory import Trajectory + from mobilerun.config_manager import ConfigLoader + + ConfigLoader.load(config_path) + logger = configure_logging(bool(verbose)) # Resolve directory (checks working dir, then package dir) resolved_dir = PathResolver.resolve(directory, must_exist=True) @@ -371,26 +350,22 @@ def list(directory: str, debug: bool): logger.debug(f"Error loading macro from {item.name}: {e}") folders.append((item.name, "Error loading", 0)) + if json_output: + emit( + [ + {"folder": folder, "description": description, "actions": actions} + for folder, description, actions in sorted(folders) + ], + json_output=True, + ) + return if not folders: logger.info("📭 No trajectory folders found") return logger.info(f"🎯 Found {len(folders)} trajectory(s):") - table = Table(title=f"Available Trajectories in {directory}") - table.add_column("Folder", style="cyan") - table.add_column("Description", style="white") - table.add_column("Actions", style="green") - - for folder, description, actions in sorted(folders): - table.add_row( - folder, - description[:80] + "..." if len(description) > 80 else description, - str(actions), - ) - - # Still use console for table display as it's structured data - console.print(table) + emit_rows(("FOLDER", "DESCRIPTION", "ACTIONS"), sorted(folders)) logger.info( f"💡 Use 'mobilerun macro replay {resolved_dir}/' to replay a trajectory" ) @@ -401,6 +376,15 @@ def list(directory: str, debug: bool): import traceback logger.debug(traceback.format_exc()) + raise click.ClickException(str(e)) from e + + +def console_render(table): + from io import StringIO + + stream = StringIO() + Console(file=stream, color_system=None).print(table) + return stream.getvalue().rstrip() if __name__ == "__main__": diff --git a/mobilerun/telemetry/phoenix.py b/mobilerun/telemetry/phoenix.py index 6cdd682e..cc9d06c0 100644 --- a/mobilerun/telemetry/phoenix.py +++ b/mobilerun/telemetry/phoenix.py @@ -51,12 +51,12 @@ def arize_phoenix_callback_handler(**kwargs: Any) -> BaseCallbackHandler: from opentelemetry.sdk.trace.export import SimpleSpanProcessor endpoint = ( - kwargs.get("endpoint", os.getenv("phoenix_url", "http://0.0.0.0:6006")) + kwargs.get("endpoint", os.getenv("PHOENIX_URL", "http://0.0.0.0:6006")) + "/v1/traces" ) resource_attributes = {} - phoenix_project_name = os.getenv("phoenix_project_name", "") + phoenix_project_name = os.getenv("PHOENIX_PROJECT_NAME", "") if phoenix_project_name.strip(): resource_attributes[ResourceAttributes.PROJECT_NAME] = phoenix_project_name resource = Resource(attributes=resource_attributes) diff --git a/mobilerun/telemetry/tracker.py b/mobilerun/telemetry/tracker.py index d85574b0..d398bfc7 100644 --- a/mobilerun/telemetry/tracker.py +++ b/mobilerun/telemetry/tracker.py @@ -54,13 +54,13 @@ def is_telemetry_enabled(*, config_enabled: bool = True) -> bool: Check whether both configuration and environment policy enable telemetry. Returns: - True when ``config_enabled`` is true and the primary or legacy telemetry + True when ``config_enabled`` is true and the telemetry environment variable is true/1/yes/y (case-insensitive). Environment - policy defaults to enabled when neither variable is set. + policy defaults to enabled when the variable is unset. """ telemetry_enabled = os.environ.get("MOBILERUN_TELEMETRY_ENABLED") if telemetry_enabled is None: - telemetry_enabled = os.environ.get("DROIDRUN_TELEMETRY_ENABLED") or "true" + telemetry_enabled = "true" enabled = config_enabled and telemetry_enabled.lower() in ["true", "1", "yes", "y"] logger.debug(f"Telemetry enabled: {enabled}") return enabled diff --git a/pyproject.toml b/pyproject.toml index f2eb9060..23bf0df8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mobilerun" -version = "0.7.4" +version = "0.8.0" description = "A framework for controlling mobile devices through LLM agents" authors = [{ name = "Niels Schmidt", email = "niels@droidrun.ai" }] dependencies = [ @@ -9,6 +9,7 @@ dependencies = [ "posthog>=6.7.6", "pydantic>=2.11.10", "rich>=14.1.0", + "segno>=1.6.6", "InquirerPy>=0.3.4", "opentelemetry-exporter-otlp-proto-http>=1.40.0", "opentelemetry-sdk>=1.40.0", @@ -24,8 +25,9 @@ dependencies = [ "llama-index-llms-google-genai>=0.8.5", "llama-index-llms-ollama>=0.7.2", "llama-index-llms-openrouter>=0.4.2", - "mobilerun-sdk>=3.2.0", - "mobilerun-core-local[cloud]>=0.6.3", + "mobilerun-sdk>=6.1.1", + "mobilerun-core[local]>=1.12.0", + "mobilerun-core-local[cloud]>=0.7.2", # Security floors also protect installations from the published wheel. "aiohttp>=3.14.4", "anyio>=4.14.2", diff --git a/tests/test_agent_model_loading.py b/tests/test_agent_model_loading.py index e6ba8c50..e0732a64 100644 --- a/tests/test_agent_model_loading.py +++ b/tests/test_agent_model_loading.py @@ -9,11 +9,11 @@ from pydantic import BaseModel from selector_helpers import CompletedRun, StubSelector +import mobilerun.cli.run as cli_run from mobilerun import MobileAgent, MobileConfig from mobilerun.agent.droid import droid_agent from mobilerun.agent.selectors import DecisionSelector, LlamaIndexLLMSelector from mobilerun.agent.utils import llm_loader -from mobilerun.cli import main as cli_main class _Output(BaseModel): @@ -23,16 +23,18 @@ class _Output(BaseModel): @pytest.fixture def cli_agents(monkeypatch): """Use real agent initialization and a completed run, never a real device.""" - monkeypatch.setattr(cli_main, "_setup_cli_logging", lambda _: None) - monkeypatch.setattr(cli_main, "print_telemetry_message", lambda **kw: None) - monkeypatch.setattr(cli_main, "_cleanup_android_keyboard", AsyncMock()) + monkeypatch.setattr(cli_run, "_setup_cli_logging", lambda _: None) + monkeypatch.setattr( + "mobilerun.telemetry.print_telemetry_message", lambda **kw: None + ) + monkeypatch.setattr(cli_run, "_cleanup_android_keyboard", AsyncMock()) agents = [] def initialize(**kwargs): agents.append(MobileAgent(**kwargs)) return SimpleNamespace(run=lambda: CompletedRun()) - monkeypatch.setattr(cli_main, "MobileAgent", initialize) + monkeypatch.setattr("mobilerun.MobileAgent", initialize) return agents @@ -559,23 +561,21 @@ async def run(): @pytest.mark.parametrize("temperature", [None, 0.0, 0.2]) -def test_cli_custom_provider_retains_original_constructor_defaults( +def test_cli_custom_provider_uses_bounded_retry_defaults( monkeypatch, cli_agents, temperature ): config = MobileConfig() load = Mock(return_value=MockLLM()) - monkeypatch.setattr(cli_main, "load_llm", load) - monkeypatch.setattr(cli_main.ConfigLoader, "load", lambda _: config) + monkeypatch.setattr("mobilerun.agent.utils.llm_picker.load_llm", load) + monkeypatch.setattr("mobilerun.config_manager.ConfigLoader.load", lambda _: config) assert ( asyncio.run( - cli_main.run_command( + cli_run.run_command( "Select", - provider="Example", + provider="OpenAILike", model="test", temperature=temperature, - base_url="https://example.test", - api_base="https://api.example.test", - max_tokens=123, + llm_url="https://example.test", debug=False, ) ) @@ -585,12 +585,11 @@ def test_cli_custom_provider_retains_original_constructor_defaults( expected: dict[str, object] = { "model": "test", "base_url": "https://example.test", - "api_base": "https://api.example.test", - "max_tokens": 123, + "max_retries": 1, } if temperature is not None: expected["temperature"] = temperature - load.assert_called_once_with("Example", **expected) + load.assert_called_once_with("OpenAILike", **expected) @pytest.mark.parametrize("reasoning", [False, True]) @@ -600,6 +599,8 @@ def test_cli_profile_overrides_reach_every_required_llm( ): config = MobileConfig() config.agent.reasoning = reasoning + for profile in config.llm_profiles.values(): + profile.provider = "OpenAILike" roles = ( ["manager", "executor", "app_opener"] if reasoning @@ -607,34 +608,31 @@ def test_cli_profile_overrides_reach_every_required_llm( ) load = Mock(return_value=dict.fromkeys(roles, MockLLM())) monkeypatch.setattr(llm_loader, "load_llms_from_profiles", load) - monkeypatch.setattr(cli_main.ConfigLoader, "load", lambda _: config) + monkeypatch.setattr("mobilerun.config_manager.ConfigLoader.load", lambda _: config) assert ( asyncio.run( - cli_main.run_command( + cli_run.run_command( "Select", temperature=temperature, - base_url="https://example.test", - api_base="https://api.example.test", - max_tokens=123, + llm_url="https://example.test", debug=False, ) ) is True ) assert len(cli_agents) == 1 - overrides: dict[str, object] = { - "base_url": "https://example.test", - "api_base": "https://api.example.test", - "max_tokens": 123, - } + overrides: dict[str, object] = {} if temperature is not None: overrides["temperature"] = temperature load.assert_called_once_with( config.llm_profiles, profile_names=roles, - **{role: overrides for role in roles}, + **({role: overrides for role in roles} if overrides else {}), ) + for role in roles: + assert config.llm_profiles[role].kwargs["base_url"] == "https://example.test" + def test_custom_constructor_options_are_copied_before_overrides(monkeypatch): load = Mock(return_value=MockLLM()) diff --git a/tests/test_android_device_selection.py b/tests/test_android_device_selection.py index 915a5934..53b77992 100644 --- a/tests/test_android_device_selection.py +++ b/tests/test_android_device_selection.py @@ -119,7 +119,7 @@ def test_multiple_online_devices_raise_with_serials(adb_devices): def test_setup_with_multiple_devices_touches_no_device( adb_devices, monkeypatch, capsys ): - from mobilerun.cli import main + import mobilerun.cli.setup as main _set(adb_devices, ("emulator-5554", "device"), ("emulator-5556", "device")) device_calls = [] @@ -137,13 +137,13 @@ async def fake_setup(*args, **kwargs): asyncio.run(main._setup_portal(path=None, device=None, debug=False)) assert device_calls == [] - out = capsys.readouterr().out + out = capsys.readouterr().err assert "emulator-5554" in out assert "emulator-5556" in out def test_setup_uses_android_serial(adb_devices, monkeypatch): - from mobilerun.cli import main + import mobilerun.cli.setup as main monkeypatch.setenv("ANDROID_SERIAL", "emulator-5556") _set(adb_devices, ("emulator-5554", "device"), ("emulator-5556", "device")) @@ -164,54 +164,32 @@ async def fake_setup(*args, **kwargs): assert device_calls == ["emulator-5556"] -def _device_command_config(monkeypatch): - from mobilerun.cli import device_commands +def test_device_command_with_multiple_devices_raises_click_error(adb_devices): + from mobilerun.cli.device_commands import device_driver + from mobilerun.cli.options import DEFAULT_API_URL - monkeypatch.setattr( - device_commands.ConfigLoader, - "load", - lambda _path: SimpleNamespace( - device=SimpleNamespace( - serial=None, - use_tcp=False, - platform="android", - auto_setup=False, - portal_mode="auto", - ) - ), - ) - serials = [] - - class FakeAndroidDriver: - def __init__(self, serial, use_tcp, portal_mode): - serials.append(serial) - - async def connect(self): - pass - - monkeypatch.setattr(device_commands, "AndroidDriver", FakeAndroidDriver) - return device_commands, serials - - -def test_device_command_with_multiple_devices_raises_click_error( - adb_devices, monkeypatch -): - device_commands, serials = _device_command_config(monkeypatch) _set(adb_devices, ("emulator-5554", "device"), ("emulator-5556", "device")) + with pytest.raises(click.ClickException, match="emulator-5554, emulator-5556"): + with device_driver({"api_url": DEFAULT_API_URL}): + pytest.fail("must not connect") - with pytest.raises(click.ClickException) as exc: - asyncio.run(device_commands._create_driver(None, None, None, False)) - assert "emulator-5554, emulator-5556" in exc.value.message - assert serials == [] +def test_device_command_uses_single_online_device(adb_devices, monkeypatch): + import mobilerun_core + from mobilerun.cli.device_commands import device_driver + from mobilerun.cli.options import DEFAULT_API_URL -def test_device_command_uses_single_online_device(adb_devices, monkeypatch): - device_commands, serials = _device_command_config(monkeypatch) _set(adb_devices, ("emulator-5556", "unauthorized"), ("emulator-5554", "device")) + serials = [] - asyncio.run(device_commands._create_driver(None, None, None, False)) + def connect(self, serial, **kwargs): + serials.append(serial) + return SimpleNamespace(close=lambda: None) + monkeypatch.setattr(mobilerun_core.Mobilerun, "connect", connect) + with device_driver({"api_url": DEFAULT_API_URL}): + pass assert serials == ["emulator-5554"] @@ -352,5 +330,8 @@ async def fake_replay(path, device, *args, **kwargs): assert result.exit_code == 0 assert devices == [None] else: - assert isinstance(result.exception, ConnectionRefusedError) + assert result.exit_code == 1 + from mobilerun.cli.options import ADB_HINT + + assert ADB_HINT in result.output assert devices == [] diff --git a/tests/test_android_portal_http_wiring.py b/tests/test_android_portal_http_wiring.py index b2d96599..23b79694 100644 --- a/tests/test_android_portal_http_wiring.py +++ b/tests/test_android_portal_http_wiring.py @@ -1,6 +1,6 @@ import asyncio +from types import SimpleNamespace -import click import pytest from mobilerun_core_local.driver.android import AndroidPortalHttpDriver @@ -37,7 +37,7 @@ def test_is_portal_http_url(device, expected): def test_portal_http_driver_requires_token(): - with pytest.raises(ValueError, match="MOBILERUN_DEVICE_TOKEN"): + with pytest.raises(ValueError, match="MOBILERUN_ANDROID_PORTAL_TOKEN"): create_android_portal_http_driver(PORTAL_URL, None) @@ -98,65 +98,76 @@ def _config_file(tmp_path): def test_device_commands_route_url_to_portal_http(tmp_path, monkeypatch, no_adb): + from mobilerun_core import Mobilerun + from mobilerun.cli import device_commands - driver = FakePortalHttpDriver() - created = _record_factory(monkeypatch, device_commands, driver) - monkeypatch.setenv("MOBILERUN_DEVICE_TOKEN", "sekret") - - result, is_ios = run( - device_commands._create_driver( - device=PORTAL_URL, - config_path=_config_file(tmp_path), - tcp=None, - ios=False, - ) - ) + created = {} - assert result is driver and driver.connected - assert is_ios is False - assert created == {"url": PORTAL_URL, "token": "sekret"} + def connect(self, device_id, **kwargs): + created.update(kwargs) + return SimpleNamespace(close=lambda: None) + + monkeypatch.setattr(Mobilerun, "connect", connect) + monkeypatch.setenv("MOBILERUN_ANDROID_PORTAL_TOKEN", "sekret") + with device_commands.device_driver( + { + "device": PORTAL_URL, + "config_path": _config_file(tmp_path), + "platform": "android", + "api_url": "https://example.test/v1", + } + ): + pass + assert created["url"] == PORTAL_URL + assert created["token"] == "sekret" + assert created["backend"] == "local-android-http" def test_device_commands_url_without_token_fails_cleanly(tmp_path, monkeypatch, no_adb): - from mobilerun.cli import device_commands + from click.testing import CliRunner - monkeypatch.delenv("MOBILERUN_DEVICE_TOKEN", raising=False) + from mobilerun.cli.main import cli - with pytest.raises(click.ClickException, match="auth token"): - run( - device_commands._create_driver( - device=PORTAL_URL, - config_path=_config_file(tmp_path), - tcp=None, - ios=False, - ) - ) + monkeypatch.delenv("MOBILERUN_ANDROID_PORTAL_TOKEN", raising=False) + result = CliRunner().invoke( + cli, ["device", "ui", "-d", PORTAL_URL, "-c", _config_file(tmp_path)] + ) + assert result.exit_code == 1 + assert "token" in result.output def test_ping_checks_portal_http_without_adb(monkeypatch, no_adb): from click.testing import CliRunner + from mobilerun_core import Mobilerun - from mobilerun.cli import main as cli_main + from mobilerun.cli.main import cli - driver = FakePortalHttpDriver() - created = _record_factory(monkeypatch, cli_main, driver) - monkeypatch.setattr( - cli_main.ConfigLoader, - "load", - lambda _: MobileConfig(device=DeviceConfig(auth_token="from-config")), - ) - monkeypatch.delenv("MOBILERUN_DEVICE_TOKEN", raising=False) + created = {} + + def connect(self, device_id, **kwargs): + created.update(kwargs) + + class Core: + def close(self): + pass + + def ui(self): + return {} - result = CliRunner().invoke(cli_main.cli, ["ping", "--device", PORTAL_URL]) + return Core() + monkeypatch.setattr(Mobilerun, "connect", connect) + monkeypatch.setenv("MOBILERUN_ANDROID_PORTAL_TOKEN", "sekret") + result = CliRunner().invoke(cli, ["device", "ping", "--device", PORTAL_URL]) assert result.exit_code == 0, result.output - assert "Portal HTTP is reachable" in result.output - assert created == {"url": PORTAL_URL, "token": "from-config"} + assert "reachable" in result.output + assert created["url"] == PORTAL_URL + assert created["token"] == "sekret" def test_keyboard_cleanup_skips_portal_http_url(no_adb): - from mobilerun.cli import main as cli_main + import mobilerun.cli.run as cli_main config = MobileConfig(device=DeviceConfig(serial=PORTAL_URL)) @@ -205,7 +216,7 @@ def test_agent_routes_url_to_portal_http_driver(monkeypatch, no_adb): created = _record_factory( monkeypatch, agent_module, FakePortalHttpDriver(ConnectFailed()) ) - monkeypatch.setenv("MOBILERUN_DEVICE_TOKEN", "sekret") + monkeypatch.setenv("MOBILERUN_ANDROID_PORTAL_TOKEN", "sekret") config = MobileConfig( device=DeviceConfig(serial=PORTAL_URL), @@ -257,7 +268,7 @@ async def get_date(self): raise NotImplementedError _record_factory(monkeypatch, agent_module, NoDateDriver()) - monkeypatch.setenv("MOBILERUN_DEVICE_TOKEN", "sekret") + monkeypatch.setenv("MOBILERUN_ANDROID_PORTAL_TOKEN", "sekret") config = MobileConfig( device=DeviceConfig(serial=PORTAL_URL), diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py new file mode 100644 index 00000000..bf2539c6 --- /dev/null +++ b/tests/test_cli_contract.py @@ -0,0 +1,911 @@ +"""The public CLI is a single, deliberately breaking command contract.""" + +import importlib +import json +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import click +import pytest +from click.testing import CliRunner + +import mobilerun.config_manager +from mobilerun.cli import auth, device_lifecycle, deviceauth +from mobilerun.cli.main import cli +from mobilerun.cli.providers import PROVIDERS, llm_url_kwargs +from mobilerun.config_manager import ConfigLoader, MobileConfig, credential_paths + +run_module = importlib.import_module("mobilerun.cli.run") + + +def commands(group=cli, path=()): + yield path, group + if isinstance(group, click.Group): + for name, command in group.commands.items(): + yield from commands(command, (*path, name)) + + +@pytest.fixture(autouse=True) +def isolated_config(monkeypatch, tmp_path): + monkeypatch.setattr( + ConfigLoader, "get_user_config_path", lambda: tmp_path / "config.yaml" + ) + monkeypatch.setattr( + credential_paths, "OAUTH_CREDENTIAL_DIR", tmp_path / "credentials" + ) + monkeypatch.setattr( + credential_paths, "AUTH_PROFILES_PATH", tmp_path / "auth-profiles.json" + ) + monkeypatch.setattr( + auth, + "SERVICES", + { + service: (tmp_path / "auth-profiles.json", variant) + for service, (_, variant) in auth.SERVICES.items() + }, + ) + for name in ( + "MOBILERUN_DEVICE", + "MOBILERUN_CONFIG", + "MOBILERUN_TARGET", + "MOBILERUN_DEBUG", + "MOBILERUN_API_BASE_URL", + "MOBILERUN_CLOUD_API_KEY", + "MOBILERUN_AUTH_URL", + ): + monkeypatch.delenv(name, raising=False) + + +@pytest.mark.parametrize("path,command", list(commands())) +@pytest.mark.parametrize("flag", ["--help", "-h"]) +def test_every_command_has_help(path, command, flag): + result = CliRunner().invoke(cli, [*path, flag]) + assert result.exit_code == 0, result.output + + +def test_tree_and_short_options(): + assert set(cli.commands) == { + "run", + "device", + "auth", + "configure", + "macro", + "doctor", + "env", + "completion", + } + assert set(cli.commands["device"].commands) == { + "list", + "connect", + "disconnect", + "setup", + "ping", + "tap", + "swipe", + "long-press", + "type", + "press", + "screenshot", + "ui", + "apps", + "start", + "pair", + } + assert set(cli.commands["auth"].commands) == {"login", "logout", "status"} + assert "--no-qr" in { + option + for param in cli.commands["auth"].commands["login"].params + for option in getattr(param, "opts", ()) + } + expected = { + "-d": "--device", + "-t": "--target", + "-P": "--platform", + "-c": "--config", + "-v": "--verbose", + "-j": "--json", + "-p": "--provider", + "-m": "--model", + "-u": "--llm-url", + "-T": "--temperature", + "-s": "--max-steps", + "-a": "--agent", + "-r": "--reasoning", + "-n": "--dry-run", + "-k": "--api-key", + "-V": "--version", + } + for path, command in commands(): + for param in command.params: + if isinstance(param, click.Option): + for short in ( + opt + for opt in param.opts + if opt.startswith("-") and not opt.startswith("--") + ): + assert expected[short] in param.opts, (path, param.opts) + + +@pytest.mark.parametrize( + "args", + [ + ["devices"], + ["connect", "serial"], + ["disconnect", "serial"], + ["setup"], + ["ping"], + ["login"], + ["logout"], + ["whoami"], + ["configure", "openai"], + ["configure", "anthropic"], + ["configure", "gemini"], + ["configure", "xai"], + ["run", "x", "--base_url", "http://x"], + ["run", "x", "--api_base", "http://x"], + ["run", "x", "--steps", "1"], + ["run", "x", "--debug"], + ["run", "x", "--ios"], + ["run", "x", "--no-vision-only"], + ["run", "x", "--control-backend", "visual-remote"], + ["run", "x", "--device-id", "x"], + ["device", "ui", "--cloud"], + ["device", "ui", "--device-id", "x"], + ["device", "ui", "--base-url", "http://x"], + ["device", "setup", "--path", "x"], + ["device", "setup", "-pv", "x"], + ], +) +def test_old_commands_and_flags_are_usage_errors(args): + result = CliRunner().invoke(cli, args) + assert result.exit_code == 2, result.output + + +@pytest.mark.parametrize( + "envvar,flag,value,override,keyword", + [ + ("MOBILERUN_DEVICE", "--device", "serial-a", "serial-b", "device"), + ("MOBILERUN_TARGET", "--target", "cloud", "local", "target"), + ( + "MOBILERUN_API_BASE_URL", + "--api-url", + "https://a.test", + "https://b.test", + "api_url", + ), + ("MOBILERUN_CONFIG", "--config", "a.yaml", "b.yaml", "config_path"), + ("MOBILERUN_DEBUG", "--verbose", "false", None, "debug"), + ], +) +def test_env_binding_and_flag_precedence( + monkeypatch, tmp_path, envvar, flag, value, override, keyword +): + if keyword == "config_path": + value, override = str(tmp_path / value), str(tmp_path / override) + Path(value).write_text("_version: 7\n") + Path(override).write_text("_version: 7\n") + callback = AsyncMock(return_value=(True, "done", 1)) + monkeypatch.setattr(run_module, "_run_task", callback) + monkeypatch.setenv(envvar, value) + result = CliRunner().invoke(cli, ["run", "task"]) + assert result.exit_code == 0, result.output + assert callback.await_args.kwargs[keyword] == ( + False if keyword == "debug" else value + ) + args = ["run", "task", flag] + ([override] if override is not None else []) + result = CliRunner().invoke(cli, args) + assert result.exit_code == 0, result.output + assert callback.await_args.kwargs[keyword] == ( + True if keyword == "debug" else override + ) + help_result = CliRunner().invoke(cli, ["run", "--help"]) + assert envvar in help_result.output + + +def test_auth_url_env_and_flag_precedence(monkeypatch): + calls = [] + monkeypatch.setattr(auth, "_login_mobilerun", lambda *args: calls.append(args)) + monkeypatch.setenv("MOBILERUN_AUTH_URL", "https://a.test") + runner = CliRunner() + assert runner.invoke(cli, ["auth", "login"]).exit_code == 0 + assert calls[-1][0] == "https://a.test" + assert ( + runner.invoke(cli, ["auth", "login", "--auth-url", "https://b.test"]).exit_code + == 0 + ) + assert calls[-1][0] == "https://b.test" + + +@pytest.mark.parametrize("args", [["env", "-j"], ["auth", "status", "-j"]]) +def test_secrets_are_never_printed(monkeypatch, args): + secret = "contract-secret-marker" + for name in ( + "MOBILERUN_CLOUD_API_KEY", + "MOBILERUN_ANDROID_PORTAL_TOKEN", + "OPENAI_API_KEY", + ): + monkeypatch.setenv(name, secret) + monkeypatch.setattr( + deviceauth, "load_credentials", lambda: {"access_token": secret} + ) + monkeypatch.setattr( + deviceauth, + "fetch_session", + lambda *_: {"user": {"email": "example@example.test"}}, + ) + result = CliRunner().invoke(cli, args) + assert result.exit_code == 0, result.output + assert secret not in result.output + assert isinstance(json.loads(result.stdout), list) + + +def test_env_sources_and_secret_config(monkeypatch, tmp_path): + config = MobileConfig() + config.device.serial = "from-config" + config.device.auth_token = "never-print-this-token" + ConfigLoader.save(config) + result = CliRunner().invoke(cli, ["env", "-j"]) + values = {row["variable"]: row for row in json.loads(result.stdout)} + assert values["MOBILERUN_DEVICE"] == { + "variable": "MOBILERUN_DEVICE", + "value": "from-config", + "source": "config", + } + assert values["MOBILERUN_ANDROID_PORTAL_TOKEN"]["value"] == "set" + assert "never-print-this-token" not in result.output + monkeypatch.setenv("MOBILERUN_DEVICE", "from-env") + values = { + row["variable"]: row + for row in json.loads(CliRunner().invoke(cli, ["env", "-j"]).stdout) + } + assert values["MOBILERUN_DEVICE"]["value"] == "from-env" + assert values["MOBILERUN_DEVICE"]["source"] == "env" + + +def test_cloud_target_without_device_does_not_touch_adb(monkeypatch): + monkeypatch.setattr( + "mobilerun.agent.utils.android_device.resolve_android_serial", + AsyncMock(side_effect=AssertionError("must not discover")), + ) + result = CliRunner().invoke(cli, ["device", "ui", "-t", "cloud"]) + assert result.exit_code == 1 + assert "mobilerun device list -t cloud" in result.output + + +@pytest.mark.parametrize( + "args", + [ + ["device", "list", "-j"], + ["device", "apps", "-j"], + ["device", "screenshot", "-j"], + ["device", "ui", "-j"], + ], +) +def test_read_commands_emit_one_document(monkeypatch, args): + import mobilerun_core + + core = SimpleNamespace( + device_id="id", + capabilities={"backend": "local-android-adb"}, + name=None, + state=None, + device_type=None, + close=Mock(), + ) + monkeypatch.setattr( + mobilerun_core.Mobilerun, "list_devices", lambda *a, **kw: [core] + ) + driver = Mock() + driver.screenshot.return_value = "cG5n" + driver.list_apps.return_value = [{"package": "example"}] + driver.ui_json.return_value = '{"a11y_tree": {}}' + monkeypatch.setattr(mobilerun_core.Mobilerun, "connect", lambda *a, **kw: driver) + args = [*args, "-d", "serial"] if "list" not in args else args + result = CliRunner().invoke(cli, args) + assert result.exit_code == 0, result.output + document = json.loads(result.stdout) + if "screenshot" in args: + Path(document["path"]).unlink() + + +def test_macro_list_json(tmp_path): + (tmp_path / "empty").mkdir() + result = CliRunner().invoke(cli, ["macro", "list", str(tmp_path), "-j"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == [] + + +def test_doctor_failure_json(monkeypatch): + from mobilerun.cli.doctor import CheckResult, Status + + monkeypatch.setattr( + "mobilerun.cli.doctor.run_doctor", + AsyncMock(return_value=[CheckResult("Device", Status.FAIL, "missing device")]), + ) + result = CliRunner().invoke(cli, ["doctor", "-j"]) + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "ok": False, + "checks": [{"name": "Device", "status": "fail", "detail": "missing device"}], + } + + +@pytest.mark.parametrize( + "service,option", + [ + ("mobilerun", "--model"), + ("xai", "--callback-host"), + ("openai", "--token"), + ("anthropic", "--model"), + ], +) +def test_auth_rejects_unsupported_options(service, option): + result = CliRunner().invoke(cli, ["auth", "login", service, option, "value"]) + assert result.exit_code == 2, result.output + assert "not supported" in result.output + + +def test_provider_table_matches_wizard_and_loaders(): + from mobilerun.agent.providers.registry import resolve_provider_variant + + for provider in PROVIDERS.values(): + if provider.family: + assert ( + resolve_provider_variant(provider.family, provider.auth_mode).id + == provider.runtime + ) + assert llm_url_kwargs("Ollama", "http://x") == {"base_url": "http://x"} + assert llm_url_kwargs("OpenAIResponses", "http://x") == {"api_base": "http://x"} + assert llm_url_kwargs("GoogleGenAI", "http://x") == { + "http_options": {"base_url": "http://x"} + } + + +def test_interrupt_exits_130(monkeypatch): + monkeypatch.setattr( + device_lifecycle.adb, "connect", AsyncMock(side_effect=KeyboardInterrupt) + ) + result = CliRunner().invoke(cli, ["device", "connect", "serial"]) + assert result.exit_code == 130 + + +def test_import_has_no_cli_global_side_effects(): + script = """ +import os +import warnings +os.environ.pop("TOKENIZERS_PARALLELISM", None) +os.environ.pop("GRPC_ENABLE_FORK_SUPPORT", None) +original = dict(os.environ) +import mobilerun.cli.main +assert dict(os.environ) == original +assert not any(action == 'ignore' and message is None and category is Warning + and module is None for action, message, category, module, *_ in warnings.filters) +""" + result = subprocess.run( + [sys.executable, "-c", script], capture_output=True, text=True + ) + assert result.returncode == 0, result.stderr + + +def fake_agent(result, calls=None): + class Handler: + async def stream_events(self): + print("progress on stderr") + if False: + yield None + + def __await__(self): + async def done(): + return result + + return done().__await__() + + class Agent: + def __init__(self, **kwargs): + if calls is not None: + calls.append(kwargs) + + def run(self): + return Handler() + + return Agent + + +@pytest.mark.parametrize("success", [True, False]) +def test_run_json_is_built_from_result_event(monkeypatch, success): + monkeypatch.setattr( + "mobilerun.MobileAgent", + fake_agent(SimpleNamespace(success=success, reason="observed result", steps=4)), + ) + result = CliRunner().invoke(cli, ["run", "task", "-j"]) + assert result.exit_code == (0 if success else 1), result.output + assert json.loads(result.stdout) == { + "success": success, + "reason": "observed result", + "steps": 4, + } + assert "progress on stderr" in result.stderr + assert "Try Mobilerun Cloud" in result.stderr + + +def test_run_injects_cloud_driver_without_agent_changes(monkeypatch): + from mobilerun_core_local.driver import cloud + + calls = [] + + class Driver: + def __init__(self, **kwargs): + calls.append(kwargs) + + async def connect(self): + pass + + monkeypatch.setattr(cloud, "CloudDriver", Driver) + monkeypatch.setattr( + "mobilerun.MobileAgent", + fake_agent(SimpleNamespace(success=True, reason="done", steps=1), calls), + ) + monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", "fake-key") + device_id = "123e4567-e89b-12d3-a456-426614174000" + result = CliRunner().invoke( + cli, + ["run", "task", "-d", device_id, "-j", "--api-url", "https://example.test/v1"], + ) + assert result.exit_code == 0, result.output + assert calls[0] == { + "device_id": device_id, + "api_key": "fake-key", + "base_url": "https://example.test/v1", + } + assert isinstance(calls[1]["driver"], Driver) + assert calls[1]["config"].device.serial is None + assert calls[1]["config"].device.auto_setup is False + assert json.loads(result.stdout)["success"] is True + + +def test_auth_logout_preserves_other_services(tmp_path): + path = tmp_path / "auth-profiles.json" + path.write_text( + json.dumps( + { + "openaiOauth": {"access": "fake-openai"}, + "grokOauth": {"access_token": "fake-xai"}, + "apiKeys": {"openai": "fake-key"}, + } + ) + ) + result = CliRunner().invoke(cli, ["auth", "logout", "openai"]) + assert result.exit_code == 0, result.output + data = json.loads(path.read_text()) + assert "openaiOauth" not in data + assert data["grokOauth"] == {"access_token": "fake-xai"} + assert data["apiKeys"] == {"openai": "fake-key"} + result = CliRunner().invoke(cli, ["auth", "logout", "openai"]) + assert result.exit_code == 0 + assert "Not logged in" in result.output + + +def test_auth_status_session_error_prints_json_without_token(monkeypatch): + monkeypatch.setattr( + deviceauth, "load_credentials", lambda: {"access_token": "fake-secret"} + ) + + def fail(*args): + raise RuntimeError("fake-secret must never be printed") + + monkeypatch.setattr(deviceauth, "fetch_session", fail) + result = CliRunner().invoke(cli, ["auth", "status", "-j"]) + assert result.exit_code == 1 + assert "session check failed" in json.loads(result.stdout)["error"] + assert "fake-secret" not in result.output + + +def test_failed_macro_replay_exits_nonzero(monkeypatch, tmp_path): + macro = importlib.import_module("mobilerun.macro.cli") + path = tmp_path / "macro.json" + path.write_text("{}") + + class Player: + def __init__(self, **kwargs): + pass + + def load_macro_from_file(self, path): + return {"description": "failing macro", "actions": [], "total_actions": 0} + + async def replay_macro(self, *args, **kwargs): + return False + + monkeypatch.setattr("mobilerun.macro.replay.MacroPlayer", Player) + monkeypatch.setattr( + macro, + "select_device", + AsyncMock(return_value=SimpleNamespace(device="serial", config=MobileConfig())), + ) + monkeypatch.setattr(macro, "_native_driver", AsyncMock(return_value=object())) + result = CliRunner().invoke(cli, ["macro", "replay", str(path)]) + assert result.exit_code == 1 + assert "Macro replay failed" in result.output + + +@pytest.mark.parametrize("stored_url", ["https://issuer.test/api/auth", None]) +def test_status_uses_only_credential_issuer(monkeypatch, stored_url): + monkeypatch.setenv("MOBILERUN_AUTH_URL", "http://other.test/api/auth") + monkeypatch.setattr( + deviceauth, + "load_credentials", + lambda: {"access_token": "marker-token", "auth_url": stored_url}, + ) + fetch = Mock(return_value={"user": {"email": "user@example.test"}}) + monkeypatch.setattr(deviceauth, "fetch_session", fetch) + result = CliRunner().invoke(cli, ["auth", "status", "-j"]) + assert result.exit_code == 0, result.output + fetch.assert_called_once_with(stored_url or auth.DEFAULT_AUTH_URL, "marker-token") + assert ( + CliRunner() + .invoke(cli, ["auth", "status", "--auth-url", "http://other.test"]) + .exit_code + == 2 + ) + + +@pytest.mark.parametrize("verb", ["connect", "disconnect"]) +def test_connection_requires_positional_and_ignores_device_env(monkeypatch, verb): + monkeypatch.setenv("MOBILERUN_DEVICE", "wrong-device") + monkeypatch.setenv("MOBILERUN_TARGET", "cloud") + call = AsyncMock( + return_value="connected to correct-device" if verb == "connect" else True + ) + monkeypatch.setattr(device_lifecycle.adb, verb, call) + runner = CliRunner() + assert runner.invoke(cli, ["device", verb]).exit_code == 2 + result = runner.invoke(cli, ["device", verb, "correct-device"]) + assert result.exit_code == 0, result.output + assert call.await_args.args == ("correct-device",) + for option in ( + "--device", + "--target", + "--platform", + "--config", + "--api-url", + "--tcp", + ): + assert ( + runner.invoke(cli, ["device", verb, "serial", option, "x"]).exit_code == 2 + ) + + +@pytest.mark.parametrize("scope", ["cloud", "all"]) +def test_cloud_list_includes_every_state_and_metadata(monkeypatch, scope): + import mobilerun_core + + monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", "fake-key") + rows = [ + SimpleNamespace( + device_id=f"id-{state}", + name=f"device-{state}", + state=state, + device_type="android", + close=Mock(), + ) + for state in ("ready", "stopped", "provisioning") + ] + list_call = Mock( + side_effect=lambda *a, **kw: rows if kw["scope"] == "cloud" else [] + ) + monkeypatch.setattr(mobilerun_core.Mobilerun, "list_devices", list_call) + flags = ["--all"] if scope == "all" else ["-t", "cloud"] + result = CliRunner().invoke(cli, ["device", "list", *flags, "-j"]) + assert result.exit_code == 0, result.output + assert [call.kwargs for call in list_call.call_args_list] == [ + {"filters": {"state": None}, "scope": target} + for target in (["local", "cloud"] if scope == "all" else ["cloud"]) + ] + data = json.loads(result.stdout) + if scope == "all": + assert data["errors"] == [] + data = data["devices"] + assert {row["state"] for row in data} == {"ready", "stopped", "provisioning"} + assert all(set(row) == {"device_id", "name", "state", "type"} for row in data) + text = CliRunner().invoke(cli, ["device", "list", *flags]) + assert all( + f"id-{state}\tdevice-{state}\t{state}\tandroid" in text.stdout + for state in ("ready", "stopped", "provisioning") + ) + + +@pytest.mark.parametrize( + "runtime,expected", + [ + ("DeepSeek", {"base_url": "http://x"}), + ("xai_oauth", None), + ("CustomProvider", None), + ], +) +def test_config_provider_url_never_crashes_with_stop_iteration( + monkeypatch, runtime, expected +): + config = MobileConfig() + for profile in config.llm_profiles.values(): + profile.provider = runtime + monkeypatch.setattr(mobilerun.config_manager.ConfigLoader, "load", lambda _: config) + monkeypatch.setattr( + "mobilerun.MobileAgent", + fake_agent(SimpleNamespace(success=True, reason="done", steps=1)), + ) + result = CliRunner().invoke(cli, ["run", "task", "-u", "http://x", "-j"]) + assert "StopIteration" not in result.output + if expected: + assert result.exit_code == 0, result.output + assert all( + profile.kwargs["base_url"] == "http://x" + for profile in config.llm_profiles.values() + ) + else: + assert result.exit_code == 2, result.output + assert result.stdout == "" + assert "not supported for provider" in result.stderr + + +@pytest.mark.parametrize("provider", ["openrouter", "deepseek"]) +def test_run_only_providers_work_and_configure_rejects_them(monkeypatch, provider): + llm = object() + load = Mock(return_value=llm) + monkeypatch.setattr("mobilerun.agent.utils.llm_picker.load_llm", load) + monkeypatch.setattr( + "mobilerun.MobileAgent", + fake_agent(SimpleNamespace(success=True, reason="done", steps=1)), + ) + result = CliRunner().invoke( + cli, ["run", "task", "-p", provider, "-m", "test-model", "-j"] + ) + assert result.exit_code == 0, result.output + assert load.call_args.args == (PROVIDERS[provider].runtime,) + result = CliRunner().invoke(cli, ["configure", "-p", provider]) + assert result.exit_code == 2 + assert "Invalid value" in result.output + + +@pytest.mark.parametrize( + "args,verb,expected_args,expected_kwargs", + [ + (["tap", "10", "20"], "tap", (10, 20), {"humanize": False}), + ( + ["swipe", "1", "2", "3", "4", "--duration", "0.4"], + "swipe", + (1, 2, 3, 4), + {"ms": 400, "humanize": False}, + ), + ( + ["long-press", "10", "20"], + "swipe", + (10, 20, 10, 20), + {"ms": 1000, "humanize": False}, + ), + ( + ["type", "hello", "--clear"], + "type", + ("hello",), + {"clear": True, "humanize": False}, + ), + (["press", "home"], "key", ("home",), {}), + (["start", "example.app"], "start_app", ("example.app",), {}), + (["apps", "--system", "-j"], "list_apps", (), {"include_system_apps": True}), + (["screenshot", "-j"], "screenshot", (), {}), + (["ui", "-j"], "ui_json", (), {}), + (["ping"], "ui", (), {}), + ], +) +def test_device_verbs_call_core_directly( + monkeypatch, args, verb, expected_args, expected_kwargs +): + import mobilerun_core + + driver = Mock() + driver.list_apps.return_value = [{"package": "example"}] + driver.screenshot.return_value = "cG5n" + driver.ui_json.return_value = '{"a11y_tree": {"text": "Hello"}}' + core = Mock() + core.connect.return_value = driver + factory = Mock(return_value=core) + monkeypatch.setattr(mobilerun_core, "Mobilerun", factory) + result = CliRunner().invoke(cli, ["device", *args, "-d", "serial"]) + assert result.exit_code == 0, result.output + getattr(driver, verb).assert_called_once_with(*expected_args, **expected_kwargs) + assert core.connect.call_args.args == ("serial",) + assert core.connect.call_args.kwargs["backend"] == "local-android-adb" + assert factory.call_args.kwargs["base_url"] == "https://api.mobilerun.ai/v1" + driver.close.assert_called_once() + core.close.assert_called_once() + if args[0] == "screenshot": + Path(json.loads(result.stdout)["path"]).unlink() + if args[0] in {"ui", "apps"}: + assert json.loads(result.stdout) + + +@pytest.mark.parametrize( + "args", + [ + ["device", "ui", "-t", "cloud"], + ["device", "list", "-t", "cloud"], + ["device", "apps", "-t", "cloud"], + ["device", "screenshot", "-t", "cloud"], + ["env"], + ["run", "task"], + ], +) +def test_json_failures_emit_exactly_one_error(monkeypatch, args, tmp_path): + if args[0] in {"env", "run"}: + monkeypatch.setenv("MOBILERUN_CONFIG", str(tmp_path / "missing.yaml")) + result = CliRunner().invoke(cli, [*args, "-j"]) + assert result.exit_code == 1, result.output + document = json.loads(result.stdout) + assert set(document) == {"error"} + assert document["error"] + + +def test_unsupported_core_operation_includes_alternative(monkeypatch): + import mobilerun_core + from mobilerun_core.connection._unsupported import UnsupportedOperation + + driver = Mock() + driver.start_app.side_effect = UnsupportedOperation( + verb="start_app", + backend="local-ios-http", + reason="unavailable", + alternative="open_deep_link", + ) + monkeypatch.setattr(mobilerun_core.Mobilerun, "connect", lambda *a, **kw: driver) + result = CliRunner().invoke(cli, ["device", "start", "example", "-d", "serial"]) + assert result.exit_code == 1 + assert "unavailable" in result.stderr and "open_deep_link" in result.stderr + + +def test_missing_portal_token_is_cli_error_before_core(monkeypatch): + import mobilerun_core + + connect = Mock() + monkeypatch.setattr(mobilerun_core.Mobilerun, "connect", connect) + monkeypatch.delenv("MOBILERUN_ANDROID_PORTAL_TOKEN", raising=False) + result = CliRunner().invoke( + cli, ["device", "tap", "1", "2", "-d", "http://127.0.0.1:9"] + ) + assert result.exit_code == 1 + assert "MOBILERUN_ANDROID_PORTAL_TOKEN" in result.stderr + connect.assert_not_called() + + +def test_env_path_and_unset_sources_and_effective_telemetry(monkeypatch, tmp_path): + config = MobileConfig() + config.telemetry.enabled = False + cfg = tmp_path / "custom.yaml" + ConfigLoader.save(config, path=cfg) + monkeypatch.setenv("MOBILERUN_TELEMETRY_ENABLED", "true") + monkeypatch.setenv("MOBILERUN_CONFIG", str(cfg)) + result = CliRunner().invoke(cli, ["env", "-j"]) + rows = {row["variable"]: row for row in json.loads(result.stdout)} + assert rows["MOBILERUN_CONFIG"]["source"] == "env" + assert rows["MOBILERUN_DEVICE"]["source"] == "not set" + assert rows["MOBILERUN_ANDROID_PORTAL_TOKEN"]["source"] == "not set" + assert rows["MOBILERUN_TELEMETRY_ENABLED"]["value"] is False + other = tmp_path / "other.yaml" + ConfigLoader.save(config, path=other) + result = CliRunner().invoke(cli, ["env", "-j", "-c", str(other)]) + row = next( + row + for row in json.loads(result.stdout) + if row["variable"] == "MOBILERUN_CONFIG" + ) + assert row["value"] == str(other) and row["source"] == "flag" + + +@pytest.mark.parametrize( + "args", + [ + ["env"], + ["run", "task"], + ["device", "ui"], + ["device", "list"], + ["auth", "status"], + ["doctor"], + ], +) +def test_missing_env_config_is_an_error(monkeypatch, tmp_path, args): + from mobilerun.cli import doctor + from mobilerun.cli.doctor import CheckResult, Status + + monkeypatch.setenv("MOBILERUN_CONFIG", str(tmp_path / "missing.yaml")) + monkeypatch.setattr( + doctor, + "check_sdk_version", + AsyncMock(return_value=CheckResult("SDK", Status.PASS, "ok")), + ) + monkeypatch.setattr( + doctor, + "check_adb", + AsyncMock(return_value=CheckResult("ADB", Status.FAIL, "not installed")), + ) + result = CliRunner().invoke(cli, args) + assert result.exit_code == 1, result.output + assert "Config" in result.output or "config" in result.output + + +def test_doctor_checks_system_before_ambiguous_device(monkeypatch): + from mobilerun.cli import doctor + from mobilerun.cli.doctor import CheckResult, Status + + order = [] + + async def sdk(debug): + order.append("sdk") + return CheckResult("SDK", Status.PASS, "ok") + + async def adb_check(debug): + order.append("adb") + return CheckResult("ADB", Status.PASS, "ok") + + async def device_check(serial, debug): + order.append("device") + return CheckResult("Device", Status.FAIL, "multiple devices"), None + + monkeypatch.setattr(doctor, "check_sdk_version", sdk) + monkeypatch.setattr(doctor, "check_adb", adb_check) + monkeypatch.setattr(doctor, "check_device", device_check) + result = CliRunner().invoke(cli, ["doctor"]) + assert result.exit_code == 1, result.output + assert order == ["sdk", "adb", "device"] + assert all(name in result.stdout for name in ("SDK", "Config", "ADB", "Device")) + + +def test_configure_api_provider_preselects_api_key_and_validates_auth_mode(monkeypatch): + configure = importlib.import_module("mobilerun.cli.configure") + wizard = Mock() + monkeypatch.setattr("mobilerun.cli.configure_wizard.run_configure_wizard", wizard) + monkeypatch.setattr(configure, "input_allowed", lambda _: True) + result = CliRunner().invoke(cli, ["configure", "-p", "openai"]) + assert result.exit_code == 0, result.output + assert wizard.call_args.kwargs["auth_mode"] == "api_key" + assert ( + CliRunner().invoke(cli, ["configure", "--auth-mode", "invalid"]).exit_code == 2 + ) + + +def test_run_help_task_and_agent_validation(): + result = CliRunner().invoke(cli, ["run", "-h"]) + assert "TASK" in result.stdout + assert ( + CliRunner().invoke(cli, ["run", "task", "-a", "no-such-agent"]).exit_code == 2 + ) + + +def test_macro_replay_unexpected_error_printed_once_with_traceback( + monkeypatch, tmp_path +): + class Player: + def __init__(self, **kwargs): + pass + + def load_macro_from_file(self, path): + raise RuntimeError("unique macro failure") + + path = tmp_path / "macro.json" + path.write_text("{}") + monkeypatch.setattr("mobilerun.macro.replay.MacroPlayer", Player) + result = CliRunner().invoke(cli, ["macro", "replay", str(path), "-n"]) + assert result.exit_code == 1 + assert result.stderr.count("unique macro failure") == 1 + verbose = CliRunner().invoke(cli, ["macro", "replay", str(path), "-n", "-v"]) + assert verbose.exit_code == 1 + assert "Traceback" in verbose.stderr + + +def test_device_commands_do_not_accept_tcp_or_unused_list_flags(): + runner = CliRunner() + for path, command in commands(cli.commands["device"], ("device",)): + assert all( + "--tcp" not in getattr(param, "opts", ()) for param in command.params + ), path + for flag in ("-a", "-d", "-P"): + assert runner.invoke(cli, ["device", "list", flag, "x"]).exit_code == 2 diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py new file mode 100644 index 00000000..d44de58c --- /dev/null +++ b/tests/test_cli_devx.py @@ -0,0 +1,1322 @@ +"""User-facing help, output, and automation contracts for the CLI.""" + +import importlib +import json +import logging +from contextlib import contextmanager +from io import StringIO +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import click +import httpx +import mobilerun_core +import pytest +from click.shell_completion import BashComplete, get_completion_class +from click.testing import CliRunner +from mobilerun_sdk import APIConnectionError, APIStatusError + +import mobilerun.cli.configure_wizard +from mobilerun.cli import ( + auth, + device_commands, + device_lifecycle, + deviceauth, + oauth_actions, + selection, +) +from mobilerun.cli.doctor import CheckResult, Status, console +from mobilerun.cli.main import cli +from mobilerun.cli.options import convert_api_error, emit_rows +from mobilerun.config_manager import ConfigLoader, env_keys +from mobilerun.macro.state import MACRO_SCHEMA_VERSION + +run_module = importlib.import_module("mobilerun.cli.run") +configure = importlib.import_module("mobilerun.cli.configure") +doctor_command = importlib.import_module("mobilerun.cli.doctor_command") + + +def commands(command=cli, path=()): + yield path, command + if isinstance(command, click.Group): + for name, child in command.commands.items(): + yield from commands(child, (*path, name)) + + +@pytest.fixture(autouse=True) +def isolated_config(monkeypatch, tmp_path): + monkeypatch.setattr( + ConfigLoader, "get_user_config_path", lambda: tmp_path / "config.yaml" + ) + monkeypatch.setenv("MOBILERUN_TELEMETRY_ENABLED", "false") + for name in ( + "MOBILERUN_CONFIG", + "MOBILERUN_TARGET", + "MOBILERUN_DEVICE", + "MOBILERUN_DEBUG", + ): + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def allow_input(monkeypatch): + def patch(tty=True): + monkeypatch.setattr( + configure, "input_allowed", lambda disabled: tty and not disabled + ) + + patch() + return patch + + +@pytest.fixture(params=["configure", "token"]) +def secret_command(request, monkeypatch, tmp_path): + callback = Mock() + if request.param == "configure": + monkeypatch.setattr( + "mobilerun.cli.configure_wizard.run_configure_wizard", callback + ) + args = ["configure", "-p", "openai", "-m", "MODEL", "-k"] + + def value(): + return callback.call_args.kwargs["api_key"] + + else: + monkeypatch.setattr( + "mobilerun.cli.oauth_actions.save_anthropic_setup_token", callback + ) + args = [ + "auth", + "login", + "anthropic", + "--credential-path", + str(tmp_path / "auth.json"), + "--token", + ] + + def value(): + return callback.call_args.args[1] + + return args, callback, value + + +@pytest.mark.parametrize( + "path", + [ + (), + ("run",), + ("device",), + ("device", "list"), + ("device", "ui"), + ("device", "pair"), + ("auth", "login"), + ("configure",), + ("macro", "replay"), + ], +) +def test_help_has_copyable_examples(path): + result = CliRunner().invoke(cli, [*path, "--help"]) + assert result.exit_code == 0, result.output + assert "Examples:\n mobilerun " in result.stdout + assert "\n mobilerun " in result.stdout + if not path: + assert result.stdout.rstrip().endswith( + "Docs: https://docs.mobilerun.ai/framework/guides/cli" + ) + + +@pytest.mark.parametrize("path,command", list(commands())) +def test_help_options_and_summaries_are_complete(path, command): + with click.Context(command, **command.context_settings) as ctx: + params = command.get_params(ctx) + for param in params: + if isinstance(param, click.Option): + assert param.help and param.help.endswith("."), (path, param.name) + assert param.show_default is not False, (path, param.name) + names = [param.name for param in params] + shared_first = [ + name + for name in ("device", "target", "platform", "config_path") + if name in names + ] + shared_last = [ + name + for name in ("api_url", "json_output", "verbose", "help") + if name in names + ] + assert names[: len(shared_first)] == shared_first + assert names[-len(shared_last) :] == shared_last + assert command.get_short_help_str(limit=1).endswith("."), path + assert not command.get_short_help_str(limit=1).endswith("..."), path + + +def test_root_command_order_and_agent_discovery(monkeypatch): + monkeypatch.setattr( + run_module, + "list_agents", + Mock(side_effect=AssertionError("root must not scan")), + ) + result = CliRunner().invoke(cli, ["--help"]) + assert result.exit_code == 0, result.output + names = [ + "run", + "device", + "configure", + "auth", + "doctor", + "env", + "macro", + "completion", + ] + assert [result.stdout.index(f" {name} ") for name in names] == sorted( + result.stdout.index(f" {name} ") for name in names + ) + + +def test_agent_option_hidden_without_installed_agents(monkeypatch): + monkeypatch.setattr(run_module, "list_agents", lambda: []) + assert "--agent" not in CliRunner().invoke(cli, ["run", "--help"]).stdout + + +def test_agent_option_rejected_without_installed_agents(monkeypatch): + monkeypatch.setattr(run_module, "list_agents", lambda: []) + result = CliRunner().invoke(cli, ["run", "TASK", "-a", "missing"]) + assert result.exit_code == 2 + assert ( + "No external agents are installed; install one before using --agent." + in result.stderr + ) + + +def test_installed_agent_is_listed_and_accepted(monkeypatch): + monkeypatch.setattr(run_module, "list_agents", lambda: ["installed"]) + runner = CliRunner() + assert "--agent [installed]" in runner.invoke(cli, ["run", "--help"]).stdout + callback = AsyncMock(return_value=(True, "done", 1)) + monkeypatch.setattr(run_module, "_run_task", callback) + assert runner.invoke(cli, ["run", "TASK", "-a", "installed"]).exit_code == 0 + assert callback.await_args.kwargs["agent"] == "installed" + + +def test_unknown_agent_is_rejected(monkeypatch): + monkeypatch.setattr(run_module, "list_agents", lambda: ["installed"]) + assert CliRunner().invoke(cli, ["run", "TASK", "-a", "missing"]).exit_code == 2 + + +@pytest.mark.parametrize( + "args,suggestion", + [ + (["devcie"], "mobilerun device"), + (["device", "lst"], "mobilerun device list"), + (["auth", "stauts"], "mobilerun auth status"), + ], +) +def test_command_typos_suggest_without_accepting(args, suggestion): + result = CliRunner().invoke(cli, args) + assert result.exit_code == 2 + assert f"Did you mean '{suggestion}'?" in result.stderr + + +def test_no_suggestion_for_unrelated_command(): + result = CliRunner().invoke(cli, ["unrelated-nonsense"]) + assert result.exit_code == 2 + assert "Did you mean" not in result.stderr + + +@pytest.mark.parametrize("path", [("run",), ("configure",), ("macro", "replay")]) +def test_provider_help_wraps(path): + result = CliRunner().invoke(cli, [*path, "--help"]) + assert "-p, --provider PROVIDER" in result.stdout + assert max(map(len, result.stdout.splitlines())) < 100 + + +@pytest.mark.parametrize("name", ["openrouter", "deepseek"]) +def test_configure_only_lists_supported_providers(name): + result = CliRunner().invoke(cli, ["configure", "--help"]) + assert name not in result.stdout + assert CliRunner().invoke(cli, ["configure", "-p", name]).exit_code == 2 + + +def test_login_usage_and_service_options(): + runner = CliRunner() + assert "[SERVICE]" in runner.invoke(cli, ["auth", "login", "--help"]).stdout + text = runner.invoke(cli, ["auth", "login", "--help"]).stdout + assert "[default: 300.0]" in text + assert "(openai, anthropic, gemini)" in " ".join(text.split()) + + +@pytest.mark.parametrize("command", ["connect", "disconnect"]) +def test_connection_usage(command): + result = CliRunner().invoke(cli, ["device", command, "--help"]) + assert "HOST:PORT" in result.stdout + + +@pytest.mark.parametrize("failed", [False, True]) +@pytest.mark.parametrize("json_output", [False, True]) +def test_doctor_preserves_checks_once(monkeypatch, failed, json_output): + + async def check(*args, **kwargs): + assert kwargs["render_results"] is False + console.print("Please enable accessibility manually") + return [ + CheckResult( + "Device", Status.FAIL if failed else Status.PASS, "observed", "detail" + ) + ] + + monkeypatch.setattr("mobilerun.cli.doctor.run_doctor", check) + result = CliRunner().invoke(cli, ["doctor", *(["-j"] if json_output else [])]) + assert result.exit_code == int(failed), result.output + assert "Please enable accessibility manually" in result.stderr + assert "Error: 1" not in result.output + if json_output: + assert json.loads(result.stdout) == { + "ok": not failed, + "checks": [ + { + "name": "Device", + "status": "fail" if failed else "pass", + "detail": "observed; detail", + } + ], + } + else: + assert result.output.count("Device:") == 1 + assert "observed; detail" in result.stdout + + +@pytest.mark.parametrize( + "message,hint", + [ + ("device 'missing' not found", "mobilerun device list"), + ("No connected Android devices found.", "mobilerun device pair"), + ], +) +def test_local_error_next_step(monkeypatch, message, hint): + + monkeypatch.setattr( + device_lifecycle, "probe_device", Mock(side_effect=RuntimeError(message)) + ) + result = CliRunner().invoke(cli, ["device", "ping", "-d", "missing"]) + assert result.exit_code == 1 + assert hint in result.stderr + if "No connected" in message: + assert "USB" in result.stderr and "-t cloud" in result.stderr + + +@pytest.mark.parametrize("tty", [False, True]) +def test_rows_only_include_terminal_header(tty): + + class Output(StringIO): + def isatty(self): + return tty + + output = Output() + with click.Context(cli, obj={"stdout": output}): + emit_rows( + ("NAME", "DETAIL"), + [("first", "line\nbreak\tcolumn\x1b\u2028\u200b"), ("last", None)], + ) + expected = "first\tline break column \nlast\t\n" + assert output.getvalue() == ("NAME\tDETAIL\n" if tty else "") + expected + + +@pytest.fixture( + params=[ + (False, False, 0, ["local", "cloud"], [], "", [1, 1]), + ( + False, + True, + 0, + ["local"], + [{"target": "cloud", "error": "cloud unavailable"}], + "Warning: cloud: cloud unavailable\n", + [1, 0], + ), + ( + True, + False, + 0, + ["cloud"], + [{"target": "local", "error": "local unavailable"}], + "Warning: local: local unavailable\n", + [0, 1], + ), + ( + True, + True, + 1, + [], + [ + {"target": "local", "error": "local unavailable"}, + {"target": "cloud", "error": "cloud unavailable"}, + ], + "Warning: local: local unavailable\nWarning: cloud: cloud unavailable\n", + [0, 0], + ), + ] +) +def all_devices_case(request, monkeypatch): + local_failure, cloud_failure, code, ids, errors, warnings, close_counts = ( + request.param + ) + clients, devices, calls = [], [], [] + + def factory(url, *, cloud): + scope = "cloud" if cloud else "local" + failure = cloud_failure if cloud else local_failure + device = SimpleNamespace( + device_id=scope, + name=None, + state=None, + device_type=None, + capabilities={"backend": scope}, + close=Mock(), + ) + devices.append(device) + + def listing(**kwargs): + calls.append(kwargs["scope"]) + if failure: + raise RuntimeError(scope + " unavailable") + return [device] + + client = SimpleNamespace(list_devices=listing, close=Mock()) + clients.append(client) + return client + + monkeypatch.setattr(device_lifecycle, "core_client", factory) + yield code, ids, errors, warnings + assert calls == ["local", "cloud"] + for client in clients: + client.close.assert_called_once() + assert [device.close.call_count for device in devices] == close_counts + + +def test_all_devices_text_retains_partial_results(all_devices_case): + code, ids, _, warnings = all_devices_case + result = CliRunner().invoke(cli, ["device", "list", "--all"]) + assert result.exit_code == code, result.output + assert [line.split("\t")[0] for line in result.stdout.splitlines()] == ids + assert result.stderr == warnings + + +def test_all_devices_json_retains_partial_results(all_devices_case): + code, ids, errors, warnings = all_devices_case + result = CliRunner().invoke(cli, ["device", "list", "--all", "-j"]) + assert result.exit_code == code, result.output + data = json.loads(result.stdout) + assert [row["device_id"] for row in data["devices"]] == ids + assert data["errors"] == errors + assert result.stderr == warnings + + +@pytest.fixture +def empty_local_without_cloud_key(monkeypatch): + local_client = SimpleNamespace(list_devices=Mock(return_value=[]), close=Mock()) + monkeypatch.setattr(selection, "resolve_cloud_api_key", lambda: None) + + def factory(url, *, cloud): + if cloud: + selection.require_cloud_api_key() + return local_client + + monkeypatch.setattr(device_lifecycle, "core_client", factory) + + +def test_all_devices_text_with_no_key_and_empty_local_is_success( + empty_local_without_cloud_key, +): + result = CliRunner().invoke(cli, ["device", "list", "--all"]) + assert result.exit_code == 0, result.output + assert result.stderr.count("Warning: cloud:") == 1 + assert ( + "USB" in result.stderr + and "mobilerun device pair" in result.stderr + and "-t cloud" in result.stderr + ) + + +def test_all_devices_json_with_no_key_and_empty_local_is_success( + empty_local_without_cloud_key, +): + result = CliRunner().invoke(cli, ["device", "list", "--all", "-j"]) + assert result.exit_code == 0, result.output + assert result.stderr.count("Warning: cloud:") == 1 + data = json.loads(result.stdout) + assert data["devices"] == [] + assert "No cloud API key" in data["errors"][0]["error"] + + +def test_connection_hint_preserves_port_and_hides_url_secrets(): + + request = httpx.Request( + "GET", "http://user:password@[::1]:9123/v1/devices?token=hidden" + ) + text, _, _ = convert_api_error(APIConnectionError(request=request)) + assert "http://[::1]:9123" in text + assert "--api-url" in text and "MOBILERUN_API_BASE_URL" in text + assert all(word not in text for word in ("password", "hidden", "user:")) + + +@pytest.mark.parametrize( + "app_field", ["package_name", "app_id", "bundle_id", "package"] +) +def test_apps_are_tsv(monkeypatch, app_field): + + @contextmanager + def driver(*args, **kwargs): + yield SimpleNamespace( + list_apps=lambda **kw: [ + { + app_field: "example.app", + "label": "Example", + "version_name": "1.0", + "version_code": 1, + "is_system_app": False, + }, + ] + ) + + monkeypatch.setattr(device_commands, "device_driver", driver) + result = CliRunner().invoke(cli, ["device", "apps"]) + assert result.exit_code == 0, result.output + assert result.stdout == "example.app\tExample\t1.0\t1\tfalse\n" + + +def test_macro_list_is_tsv(tmp_path): + + folder = tmp_path / "sample" + folder.mkdir() + (folder / "macro.json").write_text( + json.dumps( + { + "macro_schema_version": MACRO_SCHEMA_VERSION, + "description": "Example macro", + "total_actions": 3, + } + ) + ) + result = CliRunner().invoke(cli, ["macro", "list", str(tmp_path)]) + assert result.exit_code == 0, result.output + assert result.stdout == "sample\tExample macro\t3\n" + + +def test_env_text_uses_empty_values_lowercase_booleans_and_one_secret_status( + monkeypatch, +): + + monkeypatch.setattr(deviceauth, "load_credentials", lambda: None) + monkeypatch.setattr(env_keys, "resolve_env_key", lambda *args: None) + monkeypatch.setenv("MOBILERUN_ANDROID_PORTAL_TOKEN", "never-print-marker") + monkeypatch.setenv("MOBILERUN_STREAM_SCREENSHOTS", "YES") + monkeypatch.setenv("MOBILERUN_OAUTH_MANUAL", "0") + monkeypatch.delenv("ANDROID_SERIAL", raising=False) + result = CliRunner().invoke(cli, ["env"]) + assert result.exit_code == 0, result.output + assert "MOBILERUN_DEVICE= (not set)\n" in result.stdout + assert "MOBILERUN_DEBUG=false (default)\n" in result.stdout + assert "MOBILERUN_STREAM_SCREENSHOTS=true (env)\n" in result.stdout + assert "MOBILERUN_OAUTH_MANUAL=false (env)\n" in result.stdout + assert "MOBILERUN_IOS_PORTAL_TOKEN=not set\n" in result.stdout + assert "MOBILERUN_ANDROID_PORTAL_TOKEN=set (env)\n" in result.stdout + assert "never-print-marker" not in result.output + assert "None" not in result.stdout and "False" not in result.stdout + + +@pytest.mark.parametrize("tty,no_input", [(False, False), (False, True), (True, True)]) +@pytest.mark.parametrize( + "flags,missing", + [ + ([], "-p / --provider"), + (["-p", "openai"], "-m / --model"), + (["-p", "openai", "-m", "MODEL"], "-k / --api-key"), + (["-p", "openai-like", "-m", "MODEL", "-k", "dummy"], "-u / --llm-url"), + ], +) +def test_configure_without_input_fails_before_loading_or_writing( + monkeypatch, + allow_input, + tty, + no_input, + flags, + missing, + tmp_path, +): + + allow_input(tty) + monkeypatch.setattr(env_keys, "resolve_env_key", lambda *args: "") + monkeypatch.setattr( + ConfigLoader, + "load", + Mock(side_effect=AssertionError("must not load or create config")), + ) + wizard = Mock(side_effect=AssertionError("must not prompt")) + monkeypatch.setattr("mobilerun.cli.configure_wizard.run_configure_wizard", wizard) + result = CliRunner().invoke( + cli, ["configure", *flags, *(["--no-input"] if no_input else [])] + ) + assert result.exit_code == 2, result.output + assert missing in result.stderr + wizard.assert_not_called() + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize( + "stdin", ["dummy-marker\nsecond-line\n", "dummy-marker\r\n", "dummy-marker"] +) +def test_secret_from_stdin_is_one_line_and_never_printed(secret_command, stdin): + args, callback, value = secret_command + result = CliRunner().invoke(cli, [*args, "-", "--no-input", "-v"], input=stdin) + assert result.exit_code == 0, result.output + assert "dummy-marker" not in result.output and "second-line" not in result.output + callback.assert_called_once() + assert value() == "dummy-marker" + + +@pytest.mark.parametrize("stdin", ["", "\n", " \n"]) +def test_empty_secret_stdin_is_usage_error(secret_command, stdin): + args, callback, _ = secret_command + result = CliRunner().invoke(cli, [*args, "-"], input=stdin) + assert result.exit_code == 2, result.output + assert "stdin is empty" in result.stderr + callback.assert_not_called() + + +@pytest.mark.parametrize("from_stdin", [False, True]) +@pytest.mark.parametrize("verbose", [False, True]) +def test_secrets_are_redacted_from_errors_and_verbose_logs( + secret_command, from_stdin, verbose +): + args, callback, _ = secret_command + secret = "dummy-error-marker" + + def fail(*args, **kwargs): + logging.getLogger("mobilerun").error("failure containing %s", secret) + raise RuntimeError("upstream failure containing " + secret) + + callback.side_effect = fail + result = CliRunner().invoke( + cli, + [ + *args, + "-" if from_stdin else secret, + "--no-input", + *(["-v"] if verbose else []), + ], + input=secret + "\n" if from_stdin else None, + ) + assert result.exit_code == 1, result.output + assert secret not in result.output + assert "[redacted]" in result.stderr + assert ("Traceback" in result.stderr) == verbose + + +@pytest.mark.parametrize( + "service,code,calls,hints", + [ + ("mobilerun", 0, 1, ()), + ("openai", 0, 1, ()), + ("xai", 0, 1, ()), + ("anthropic", 2, 0, ("--token",)), + ("gemini", 2, 0, ("terminal", "--no-input")), + ], +) +def test_login_only_blocks_flows_that_need_terminal_input( + monkeypatch, service, code, calls, hints +): + + monkeypatch.setattr( + "mobilerun.cli.oauth_actions.login_requires_input", + lambda name: name in {"anthropic", "gemini"}, + ) + callbacks = [] + for name in ( + "_login_mobilerun", + "_run_openai_oauth_login", + "_run_grok_oauth_login", + "_run_anthropic_oauth_login", + "_run_gemini_oauth_login", + ): + callback = Mock() + monkeypatch.setattr(auth, name, callback) + callbacks.append(callback) + result = CliRunner().invoke(cli, ["auth", "login", service, "--no-input"]) + assert result.exit_code == code, result.output + assert sum(callback.call_count for callback in callbacks) == calls + for hint in hints: + assert hint in result.stderr + + +@pytest.mark.parametrize("service", ["anthropic", "gemini"]) +def test_provider_callback_login_without_manual_prompt_is_allowed(monkeypatch, service): + + monkeypatch.setattr( + "mobilerun.cli.oauth_actions.login_requires_input", lambda _: False + ) + callback = Mock() + monkeypatch.setattr(auth, f"_run_{service}_oauth_login", callback) + result = CliRunner().invoke(cli, ["auth", "login", service, "--no-input"]) + assert result.exit_code == 0, result.output + callback.assert_called_once() + + +@pytest.mark.parametrize("failure,code", [(EOFError, 1), (KeyboardInterrupt, 130)]) +def test_prompt_eof_and_interrupt_have_distinct_exit_codes( + monkeypatch, allow_input, failure, code +): + monkeypatch.setattr( + "mobilerun.cli.configure_wizard.run_configure_wizard", Mock(side_effect=failure) + ) + result = CliRunner().invoke(cli, ["configure"]) + assert result.exit_code == code, result.output + if code == 1: + assert ( + result.stderr + == "Error: Input closed; run the command again in a terminal.\n" + ) + + +def test_click_prompt_interrupt_exits_130(monkeypatch, allow_input): + def prompt(*args, **kwargs): + monkeypatch.setattr( + click.termui, "visible_prompt_func", Mock(side_effect=KeyboardInterrupt) + ) + return click.prompt("Value") + + monkeypatch.setattr("mobilerun.cli.configure_wizard.run_configure_wizard", prompt) + result = CliRunner().invoke(cli, ["configure"]) + assert result.exit_code == 130, result.output + assert "Input closed" not in result.output + + +def test_no_input_configure_resolves_provider_environment_key(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "dummy-environment-marker") + wizard = Mock() + monkeypatch.setattr("mobilerun.cli.configure_wizard.run_configure_wizard", wizard) + result = CliRunner().invoke( + cli, ["configure", "-p", "openai", "-m", "MODEL", "--no-input"] + ) + assert result.exit_code == 0, result.output + assert "dummy-environment-marker" not in result.output + assert wizard.call_args.kwargs["api_key"] is None + + +def test_no_input_configure_never_starts_missing_oauth_login(monkeypatch): + monkeypatch.setattr( + "mobilerun.cli.configure_wizard._oauth_credentials_present", lambda *args: False + ) + wizard = Mock() + monkeypatch.setattr("mobilerun.cli.configure_wizard.run_configure_wizard", wizard) + result = CliRunner().invoke( + cli, ["configure", "-p", "openai-oauth", "-m", "MODEL", "--no-input"] + ) + assert result.exit_code == 2, result.output + assert "mobilerun auth login openai" in result.stderr + wizard.assert_not_called() + + +def test_click_prompt_eof_is_one_failure_sentence(monkeypatch, allow_input): + monkeypatch.setattr( + mobilerun.cli.configure_wizard, + "run_configure_wizard", + lambda *args, **kwargs: click.prompt("Value"), + ) + result = CliRunner().invoke(cli, ["configure"], input="") + assert result.exit_code == 1, result.output + assert ( + result.stderr.count("Input closed; run the command again in a terminal.") == 1 + ) + assert "Aborted!" not in result.output + + +@pytest.mark.parametrize( + "service,headless,manual,expected", + [ + ("gemini", False, "", False), + ("anthropic", False, "", False), + ("gemini", True, "", True), + ("anthropic", True, "", True), + ("gemini", False, "true", True), + ("anthropic", False, "1", True), + ("openai", True, "true", False), + ("xai", True, "true", False), + ("mobilerun", True, "true", False), + ("gemini", False, "0", False), + ], +) +def test_manual_login_detection(monkeypatch, service, headless, manual, expected): + monkeypatch.setattr(oauth_actions, "_is_headless_environment", lambda: headless) + monkeypatch.setenv("MOBILERUN_OAUTH_MANUAL", manual) + assert oauth_actions.login_requires_input(service) is expected + + +@pytest.mark.parametrize("shell", ["bash", "zsh", "fish"]) +def test_completion_is_click_source_without_installing(shell, tmp_path, monkeypatch): + + monkeypatch.setattr(BashComplete, "_check_version", staticmethod(lambda: None)) + monkeypatch.setenv("MOBILERUN_CONFIG", str(tmp_path / "missing.yaml")) + result = CliRunner().invoke(cli, ["completion", shell]) + assert result.exit_code == 0, result.output + expected = get_completion_class(shell)( + cli, {}, "mobilerun", "_MOBILERUN_COMPLETE" + ).source() + assert result.stdout == expected + "\n" + assert "_MOBILERUN_COMPLETE" in result.stdout + assert result.stderr == "" + assert list(tmp_path.iterdir()) == [] + + +def test_completion_suggests_device_subcommands(): + result = CliRunner().invoke( + cli, + [], + prog_name="mobilerun", + env={ + "_MOBILERUN_COMPLETE": "bash_complete", + "COMP_WORDS": "mobilerun device l", + "COMP_CWORD": "2", + }, + ) + assert result.exit_code == 0, result.output + assert result.stdout.splitlines() == ["plain,list", "plain,long-press"] + + +def test_completion_rejects_unknown_shell(): + result = CliRunner().invoke(cli, ["completion", "unknown"]) + assert result.exit_code == 2, result.output + + +@pytest.mark.parametrize( + "names,expected", [([], []), (["installed", "other"], ["plain,installed"])] +) +def test_agent_completion_discovers_installed_names(monkeypatch, names, expected): + monkeypatch.setattr(run_module, "list_agents", lambda: names) + result = CliRunner().invoke( + cli, + [], + prog_name="mobilerun", + env={ + "_MOBILERUN_COMPLETE": "bash_complete", + "COMP_WORDS": "mobilerun run TASK -a i", + "COMP_CWORD": "4", + }, + ) + assert result.exit_code == 0, result.output + assert result.stdout.strip().splitlines() == expected + + +@pytest.mark.parametrize( + "words,cword", + [ + ("mobilerun configure -k - ", "4"), + ("mobilerun auth login anthropic --token - ", "6"), + ], +) +def test_completion_does_not_read_secret_stdin(monkeypatch, words, cword): + reader = Mock(side_effect=AssertionError("completion must not access stdin")) + monkeypatch.setattr(click, "get_text_stream", reader) + result = CliRunner().invoke( + cli, + [], + prog_name="mobilerun", + input="dummy-secret\n", + env={ + "_MOBILERUN_COMPLETE": "bash_complete", + "COMP_WORDS": words, + "COMP_CWORD": cword, + }, + ) + assert result.exit_code == 0, result.output + reader.assert_not_called() + + +def test_terminal_secret_input_is_hidden(monkeypatch, secret_command): + args, callback, value = secret_command + stream = Mock() + stream.isatty.return_value = True + original_stream = click.get_text_stream + monkeypatch.setattr( + click, + "get_text_stream", + lambda name: stream if name == "stdin" else original_stream(name), + ) + original_prompt = click.prompt + + def hidden_prompt(*args, **kwargs): + assert kwargs["hide_input"] is True and kwargs["err"] is True + monkeypatch.setattr( + click.termui, "hidden_prompt_func", Mock(return_value="dummy-hidden-marker") + ) + return original_prompt(*args, **kwargs) + + monkeypatch.setattr(click, "prompt", hidden_prompt) + result = CliRunner().invoke(cli, [*args, "-", "--no-input"]) + assert result.exit_code == 0, result.output + callback.assert_called_once() + stream.readline.assert_not_called() + assert value() == "dummy-hidden-marker" + assert "dummy-hidden-marker" not in result.output + assert ":" in result.stderr + + +def test_cloud_empty_list_has_cloud_hint(monkeypatch): + monkeypatch.setattr(device_lifecycle, "_list_target", lambda *a: []) + result = CliRunner().invoke(cli, ["device", "list", "-t", "cloud"]) + assert result.exit_code == 0, result.output + assert result.stdout == "" + assert "Create or start a device in Mobilerun Cloud" in result.stderr + assert "sign-in" in result.stderr + assert all(text not in result.stderr for text in ("USB", "pair", "-t cloud")) + + +def test_cloud_missing_device_has_cloud_listing_hint(monkeypatch): + monkeypatch.setattr( + device_lifecycle, + "probe_device", + Mock(side_effect=RuntimeError("device not found")), + ) + result = CliRunner().invoke(cli, ["device", "ping", "-t", "cloud", "-d", "missing"]) + assert result.exit_code == 1 + assert "`mobilerun device list -t cloud`" in result.stderr + + +def test_all_devices_api_error_is_short_and_redacted(monkeypatch): + monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", "dummy-api-marker") + request = httpx.Request("GET", "http://127.0.0.1:9/v1/devices") + response = httpx.Response(404, request=request) + error = APIStatusError( + "missing", + response=response, + body={"detail": "device\n dummy-api-marker not found"}, + ) + monkeypatch.setattr(device_lifecycle, "_list_target", Mock(side_effect=[[], error])) + result = CliRunner().invoke(cli, ["device", "list", "--all", "-j"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == { + "devices": [], + "errors": [ + {"target": "cloud", "error": "device [redacted] not found"}, + ], + } + assert "dummy-api-marker" not in result.output + + +def test_env_json_booleans_match_text(monkeypatch): + monkeypatch.setenv("MOBILERUN_STREAM_SCREENSHOTS", "YES") + monkeypatch.setenv("MOBILERUN_OAUTH_MANUAL", "0") + result = CliRunner().invoke(cli, ["env", "-j"]) + assert result.exit_code == 0, result.output + values = {row["variable"]: row["value"] for row in json.loads(result.stdout)} + assert values["MOBILERUN_STREAM_SCREENSHOTS"] is True + assert values["MOBILERUN_OAUTH_MANUAL"] is False + + +@pytest.mark.parametrize("secret", ["", " "]) +def test_terminal_empty_secret_is_usage_error(monkeypatch, secret_command, secret): + args, callback, _ = secret_command + monkeypatch.setattr( + click, "get_text_stream", lambda name: SimpleNamespace(isatty=lambda: True) + ) + original_prompt = click.prompt + + def prompt(*args, **kwargs): + monkeypatch.setattr(click.termui, "hidden_prompt_func", lambda _: secret) + return original_prompt(*args, **kwargs) + + monkeypatch.setattr(click, "prompt", prompt) + result = CliRunner().invoke(cli, [*args, "-"]) + assert result.exit_code == 2, result.output + assert "stdin is empty" in result.stderr + callback.assert_not_called() + + +@pytest.mark.parametrize("flags", [[], ["-j"]]) +def test_doctor_runner_suppresses_results_and_keeps_instructions(monkeypatch, flags): + module = importlib.import_module("mobilerun.cli.doctor") + config = SimpleNamespace(device=SimpleNamespace(serial=None, platform="ios")) + monkeypatch.setattr( + module, + "check_sdk_version", + AsyncMock(return_value=CheckResult("SDK", Status.PASS, "ready")), + ) + monkeypatch.setattr( + module, + "check_config", + AsyncMock( + return_value=( + CheckResult("Config", Status.PASS, "ready"), + config, + ) + ), + ) + monkeypatch.setattr( + module, + "check_adb", + AsyncMock(return_value=CheckResult("ADB", Status.PASS, "ready")), + ) + monkeypatch.setattr(selection, "select_device", AsyncMock(return_value=object())) + monkeypatch.setattr( + device_commands, + "probe_device", + lambda *a, **kw: console.print("Please enable manually"), + ) + result = CliRunner().invoke(cli, ["doctor", *flags]) + assert result.exit_code == 0, result.output + assert result.stderr.strip() == "Please enable manually" + assert "All checks passed" not in result.output + assert "Mobilerun Doctor" not in result.output + + +@pytest.mark.parametrize( + "args,message", + [ + (["login"], "No such command 'login'. Did you mean 'mobilerun auth login'?"), + (["logout"], "No such command 'logout'. Did you mean 'mobilerun auth logout'?"), + ( + ["connect", "X"], + "No such command 'connect'. Did you mean 'mobilerun device connect'?", + ), + ( + ["disconnect", "X"], + "No such command 'disconnect'. Did you mean 'mobilerun device disconnect'?", + ), + (["setup"], "No such command 'setup'. Did you mean 'mobilerun device setup'?"), + (["ping"], "No such command 'ping'. Did you mean 'mobilerun device ping'?"), + (["pair"], "No such command 'pair'. Did you mean 'mobilerun device pair'?"), + ( + ["devices"], + "No such command 'devices'. Did you mean 'mobilerun device list'?", + ), + (["whoami"], "No such command 'whoami'. Did you mean 'mobilerun auth status'?"), + (["lst"], "No such command 'lst'. Did you mean 'mobilerun device list'?"), + (["devcie"], "No such command 'devcie'. Did you mean 'mobilerun device'?"), + ( + ["device", "status"], + "No such command 'status'. Did you mean 'mobilerun auth status'?", + ), + ( + ["auth", "lst"], + "No such command 'lst'. Did you mean 'mobilerun device list'?", + ), + ( + ["login", "-h"], + "No such command 'login'. Did you mean 'mobilerun auth login'?", + ), + ( + ["login", "auth"], + "No such command 'login'. Did you mean 'mobilerun auth login'?", + ), + ( + ["list"], + "No such command 'list'. Did you mean 'mobilerun device list', 'mobilerun macro list'?", + ), + ( + ["configure", "openai"], + "Got unexpected extra argument (openai). Use 'mobilerun auth login openai' to sign in.", + ), + ], +) +def test_unknown_commands_suggest_paths_without_execution(monkeypatch, args, message): + callbacks = [] + for _, command in commands(): + if not isinstance(command, click.Group): + callback = Mock(side_effect=AssertionError("must not execute a command")) + monkeypatch.setattr(command, "callback", callback) + callbacks.append(callback) + result = CliRunner().invoke(cli, args) + assert result.exit_code == 2, result.output + assert result.stdout == "" + assert result.stderr.splitlines()[-1] == "Error: " + message + assert result.stderr.count("Did you mean") <= 1 + for callback in callbacks: + callback.assert_not_called() + + +@pytest.mark.parametrize( + "words,cword", + [ + ("mobilerun login ", "2"), + ("mobilerun device status ", "3"), + ("mobilerun devices ", "2"), + ("mobilerun configure openai ", "3"), + ], +) +def test_completion_never_suggests_command_corrections(words, cword): + result = CliRunner().invoke( + cli, + [], + prog_name="mobilerun", + env={ + "_MOBILERUN_COMPLETE": "bash_complete", + "COMP_WORDS": words, + "COMP_CWORD": cword, + }, + ) + assert result.exit_code == 0, result.output + assert result.stderr == "" + assert "Did you mean" not in result.stdout + assert "to sign in" not in result.stdout + + +@pytest.fixture(params=["MOBILERUN_API_KEY", "MOBILERUN_CLOUD_API_KEY"]) +def cloud_env_var(request): + return request.param + + +@pytest.fixture( + params=[ + ( + "planted-env-key", + "planted-saved-token", + "planted-env-key", + "env", + True, + "env key", + "env", + True, + "The key in MOBILERUN_CLOUD_API_KEY was rejected. " + "A saved sign-in exists; unset the variable to use it.", + ), + ( + "planted-env-key", + None, + "planted-env-key", + "env", + False, + "env key", + "env", + False, + "The key in MOBILERUN_CLOUD_API_KEY was rejected. " + "Check the key, or unset the variable and run `mobilerun auth login`.", + ), + ( + None, + "planted-saved-token", + "planted-saved-token", + "login", + True, + "saved sign-in", + "credential file", + False, + "The saved sign-in was rejected or has expired. Run `mobilerun auth login`.", + ), + ] +) +def cloud_credential_case(request, monkeypatch, cloud_env_var): + env, saved, key, source, has_login, label, env_source, overrides, hint = ( + request.param + ) + hint = hint.replace("MOBILERUN_CLOUD_API_KEY", cloud_env_var) + monkeypatch.delenv("MOBILERUN_API_KEY", raising=False) + monkeypatch.delenv("MOBILERUN_CLOUD_API_KEY", raising=False) + if env is not None: + monkeypatch.setenv(cloud_env_var, env) + credentials = {"access_token": saved} if saved else None + monkeypatch.setattr(deviceauth, "load_credentials", lambda: credentials) + return SimpleNamespace( + key=key, + source=source, + has_login=has_login, + label=label, + env_source=env_source, + overrides=overrides, + hint=hint, + ) + + +@pytest.fixture +def rejected_cloud_command(monkeypatch, cloud_credential_case): + def invoke(status, flags): + request = httpx.Request( + "GET", + "http://127.0.0.1:9/v1/devices", + headers={ + "Authorization": "Bearer " + cloud_credential_case.key, + }, + ) + error = APIStatusError( + "rejected", + response=httpx.Response(status, request=request), + body={"detail": "Rejected " + cloud_credential_case.key}, + ) + listing = Mock(side_effect=error) + client = SimpleNamespace(list_devices=listing, close=Mock()) + factory = Mock(return_value=client) + monkeypatch.setattr(mobilerun_core, "Mobilerun", factory) + result = CliRunner().invoke(cli, ["device", "list", "-t", "cloud", *flags]) + assert result.exit_code == 1, result.output + factory.assert_called_once_with( + bearer=cloud_credential_case.key, base_url="https://api.mobilerun.ai/v1" + ) + listing.assert_called_once_with(filters={"state": None}, scope="cloud") + client.close.assert_called_once() + assert "planted-env-key" not in result.output + assert "planted-saved-token" not in result.output + return result + + return invoke + + +@pytest.mark.parametrize("status", [401, 403]) +def test_cloud_rejection_text_names_used_credential( + cloud_credential_case, rejected_cloud_command, status +): + result = rejected_cloud_command(status, []) + hint = ( + f"The {cloud_credential_case.label} is valid but not allowed to do this; " + "check its scopes or the device owner." + if status == 403 + else cloud_credential_case.hint + ) + assert result.stdout == "" + assert result.stderr == f"Error: Rejected [redacted] (HTTP {status})\n{hint}\n" + + +@pytest.mark.parametrize("status", [401, 403]) +def test_cloud_rejection_json_names_used_credential( + cloud_credential_case, rejected_cloud_command, status +): + result = rejected_cloud_command(status, ["-j"]) + hint = ( + f"The {cloud_credential_case.label} is valid but not allowed to do this; " + "check its scopes or the device owner." + if status == 403 + else cloud_credential_case.hint + ) + assert json.loads(result.stdout) == { + "error": "Rejected [redacted]", + "status": status, + "code": None, + "credential": cloud_credential_case.source, + } + assert result.stderr == f"Error: Rejected [redacted] (HTTP {status})\n{hint}\n" + + +@pytest.mark.parametrize( + "env,warning", + [ + (None, ""), + ( + "planted-env-key", + "Warning: MOBILERUN_CLOUD_API_KEY is set and takes precedence over this sign-in; " + "unset it to use the sign-in.\n", + ), + ], +) +def test_successful_cloud_login_warns_about_env_override(monkeypatch, env, warning): + monkeypatch.delenv("MOBILERUN_CLOUD_API_KEY", raising=False) + if env is not None: + monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", env) + monkeypatch.setattr( + deviceauth, "load_credentials", lambda: {"access_token": "planted-saved-token"} + ) + login = Mock() + monkeypatch.setattr(auth, "_login_mobilerun", login) + result = CliRunner().invoke(cli, ["auth", "login"]) + assert result.exit_code == 0, result.output + login.assert_called_once() + assert result.stdout == "" + assert result.stderr == warning + assert "planted-env-key" not in result.output + assert "planted-saved-token" not in result.output + + +def test_cloud_credential_resolver_preserves_precedence(cloud_credential_case): + credential = selection.resolve_cloud_credential() + assert credential.value == cloud_credential_case.key + assert credential.source == cloud_credential_case.source + assert credential.saved_sign_in is cloud_credential_case.has_login + assert selection.require_cloud_api_key() == cloud_credential_case.key + assert "planted-env-key" not in repr(credential) + assert "planted-saved-token" not in repr(credential) + + +@pytest.fixture +def cloud_status(monkeypatch, tmp_path, cloud_credential_case): + monkeypatch.setattr(deviceauth, "credential_path", lambda: tmp_path / "cloud.json") + monkeypatch.setattr( + deviceauth, + "fetch_session", + Mock(return_value={"user": {"email": "user@example.test"}}), + ) + monkeypatch.setattr( + "mobilerun.cli.configure_wizard._oauth_credentials_present", lambda *a: False + ) + + +def test_cloud_status_json_names_active_credential( + cloud_status, cloud_credential_case, tmp_path +): + result = CliRunner().invoke(cli, ["auth", "status", "-j"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)[0] == { + "service": "mobilerun", + "signed_in": cloud_credential_case.has_login, + "credential_path": str(tmp_path / "cloud.json"), + "email": "user@example.test" if cloud_credential_case.has_login else None, + "cloud_credential": cloud_credential_case.source, + "env_overrides_login": cloud_credential_case.overrides, + } + assert "planted-env-key" not in result.output + assert "planted-saved-token" not in result.output + + +def test_cloud_status_text_names_active_credential(cloud_status, cloud_credential_case): + result = CliRunner().invoke(cli, ["auth", "status"]) + assert result.exit_code == 0, result.output + line = result.stdout.splitlines()[0] + assert f"cloud commands use {cloud_credential_case.label}" in line + assert ("overrides saved sign-in" in line) is cloud_credential_case.overrides + assert "planted-env-key" not in result.output + assert "planted-saved-token" not in result.output + + +def test_env_json_uses_cloud_credential_source(cloud_credential_case): + result = CliRunner().invoke(cli, ["env", "-j"]) + assert result.exit_code == 0, result.output + row = next( + row + for row in json.loads(result.stdout) + if row["variable"] == "MOBILERUN_API_KEY" + ) + assert row == { + "variable": "MOBILERUN_API_KEY", + "value": "set", + "source": cloud_credential_case.source, + } + assert "planted-env-key" not in result.output + assert "planted-saved-token" not in result.output + + +def test_env_text_uses_cloud_credential_source(cloud_credential_case): + result = CliRunner().invoke(cli, ["env"]) + assert result.exit_code == 0, result.output + assert f"MOBILERUN_API_KEY=set ({cloud_credential_case.label})\n" in result.stdout + assert "planted-env-key" not in result.output + assert "planted-saved-token" not in result.output + + +@pytest.mark.parametrize("status", [401, 403]) +def test_api_error_without_credential_suggests_login(monkeypatch, status): + monkeypatch.setattr( + selection, + "resolve_cloud_credential", + lambda: selection.CloudCredential(None, None, False), + ) + request = httpx.Request("GET", "https://example.test/v1/devices") + error = APIStatusError( + "rejected", response=httpx.Response(status, request=request), body={} + ) + text, document, _ = convert_api_error(error) + assert document["credential"] is None + assert text.endswith("Run `mobilerun auth login` or set MOBILERUN_API_KEY.") + assert "saved sign-in" not in text + + +@pytest.mark.parametrize("path", [path for path, _ in commands()]) +def test_every_command_examples_and_verbose(path): + result = CliRunner().invoke(cli, [*path, "--help"]) + assert result.exit_code == 0 + assert "Examples:" in result.stdout + assert "--verbose" in result.stdout diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py new file mode 100644 index 00000000..5e027df9 --- /dev/null +++ b/tests/test_cli_exit_codes.py @@ -0,0 +1,149 @@ +import importlib +from unittest.mock import AsyncMock + +import pytest +from click.testing import CliRunner + +import mobilerun.config_manager +from mobilerun.cli import device_lifecycle, main +from mobilerun.config_manager import MobileConfig + +run_module = importlib.import_module("mobilerun.cli.run") + + +@pytest.fixture(autouse=True) +def config(monkeypatch): + monkeypatch.setattr( + mobilerun.config_manager.ConfigLoader, "load", lambda _: MobileConfig() + ) + + +def test_connect_fresh_success(monkeypatch): + connect = AsyncMock(return_value="connected to 127.0.0.1:5555") + monkeypatch.setattr(device_lifecycle.adb, "connect", connect) + + result = CliRunner().invoke(main.cli, ["device", "connect", "127.0.0.1:5555"]) + + assert result.exit_code == 0, result.output + assert "Successfully connected to 127.0.0.1:5555" in result.output + connect.assert_awaited_once_with("127.0.0.1:5555") + + +def test_connect_already_connected(monkeypatch): + monkeypatch.setattr( + device_lifecycle.adb, + "connect", + AsyncMock(return_value="already connected to device"), + ) + + result = CliRunner().invoke(main.cli, ["device", "connect", "device"]) + + assert result.exit_code == 0, result.output + assert "Successfully connected to device" in result.output + + +def test_connect_failure_string(monkeypatch): + monkeypatch.setattr( + device_lifecycle.adb, "connect", AsyncMock(return_value="connection refused") + ) + + result = CliRunner().invoke(main.cli, ["device", "connect", "device"]) + + assert result.exit_code == 1 + assert "Error: Failed to connect to device: connection refused" in result.output + + +def test_connect_exception(monkeypatch): + monkeypatch.setattr( + device_lifecycle.adb, + "connect", + AsyncMock(side_effect=RuntimeError("connection broke")), + ) + + result = CliRunner().invoke(main.cli, ["device", "connect", "device"]) + + assert result.exit_code == 1 + assert "Error: connection broke" in result.output + + +def test_disconnect_exception(monkeypatch): + disconnect = AsyncMock(side_effect=RuntimeError("connection broke")) + monkeypatch.setattr(device_lifecycle.adb, "disconnect", disconnect) + + result = CliRunner().invoke(main.cli, ["device", "disconnect", "device"]) + + assert result.exit_code == 1 + assert "Error: connection broke" in result.output + disconnect.assert_awaited_once_with("device", raise_error=True) + + +def test_disconnect_not_connected_is_success(monkeypatch): + monkeypatch.setattr( + device_lifecycle.adb, "disconnect", AsyncMock(return_value=False) + ) + + result = CliRunner().invoke(main.cli, ["device", "disconnect", "device"]) + + assert result.exit_code == 0, result.output + assert "Device device was not connected" in result.output + + +def test_ping_unknown_device(monkeypatch): + monkeypatch.setattr( + device_lifecycle, + "probe_device", + lambda *_: (_ for _ in ()).throw(RuntimeError("unknown device")), + ) + result = CliRunner().invoke(main.cli, ["device", "ping", "-d", "no-such-device"]) + assert result.exit_code == 1 + assert "Error: unknown device" in result.output + + +def test_ping_driver_error(monkeypatch): + monkeypatch.setattr( + device_lifecycle, + "probe_device", + lambda *_: (_ for _ in ()).throw(RuntimeError("portal broke")), + ) + result = CliRunner().invoke( + main.cli, ["device", "ping", "-d", "device", "--verbose"] + ) + assert result.exit_code == 1 + assert "Error: portal broke" in result.output + assert "Traceback" in result.stderr + + +def test_device_list_core_error_is_failure(monkeypatch): + class FakeCore: + def close(self): + pass + + def list_devices(self, **kwargs): + raise RuntimeError("adb broke") + + monkeypatch.setattr(device_lifecycle, "core_client", lambda *_, **kw: FakeCore()) + result = CliRunner().invoke(main.cli, ["device", "list"]) + assert result.exit_code == 1 + assert "adb broke" in result.output + + +def test_run_provider_without_model(monkeypatch): + run_command = AsyncMock() + monkeypatch.setattr(run_module, "run_command", run_command) + + result = CliRunner().invoke(main.cli, ["run", "x", "--provider", "OpenAI"]) + + assert result.exit_code == 2 + assert "--provider and --model must be given together" in result.output + run_command.assert_not_awaited() + + +def test_run_model_without_provider(monkeypatch): + run_command = AsyncMock() + monkeypatch.setattr(run_module, "run_command", run_command) + + result = CliRunner().invoke(main.cli, ["run", "x", "--model", "example"]) + + assert result.exit_code == 2 + assert "--provider and --model must be given together" in result.output + run_command.assert_not_awaited() diff --git a/tests/test_cli_polish_fix2.py b/tests/test_cli_polish_fix2.py new file mode 100644 index 00000000..9afc1171 --- /dev/null +++ b/tests/test_cli_polish_fix2.py @@ -0,0 +1,501 @@ +"""Platform token, UI text and safe SDK-error contracts.""" + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest +from click import ClickException +from click.testing import CliRunner +from mobilerun_core import UiState +from mobilerun_sdk import APIConnectionError, APIStatusError, Mobilerun + +from mobilerun.cli import device_lifecycle, deviceauth, selection +from mobilerun.cli import run as run_module +from mobilerun.cli.main import cli +from mobilerun.config_manager import ConfigLoader, MobileConfig +from mobilerun.config_manager.config_manager import DeviceConfig + + +@pytest.fixture(autouse=True) +def isolated_config(monkeypatch, tmp_path): + monkeypatch.setattr( + ConfigLoader, "get_user_config_path", lambda: tmp_path / "config.yaml" + ) + for name in ( + "MOBILERUN_ANDROID_PORTAL_TOKEN", + "MOBILERUN_IOS_PORTAL_TOKEN", + "MOBILERUN_CONFIG", + "MOBILERUN_DEVICE", + "MOBILERUN_TARGET", + ): + monkeypatch.delenv(name, raising=False) + + +@pytest.mark.parametrize( + "platform,name,other_name", + [ + ("android", "MOBILERUN_ANDROID_PORTAL_TOKEN", "MOBILERUN_IOS_PORTAL_TOKEN"), + ("ios", "MOBILERUN_IOS_PORTAL_TOKEN", "MOBILERUN_ANDROID_PORTAL_TOKEN"), + ], +) +def test_platform_tokens_override_config_without_crossing_platforms( + monkeypatch, + platform, + name, + other_name, +): + other = "ios" if platform == "android" else "android" + config = DeviceConfig(platform=platform, auth_token="config-token") + assert config.resolve_auth_token() == "config-token" + assert config.resolve_auth_token(platform) == "config-token" + assert config.resolve_auth_token(other) is None + selected = selection.Selection( + MobileConfig(device=config), + "http://localhost:9", + other, + f"local-{other}-http", + ) + if other == "ios": + assert selection.portal_token(selected) is None + else: + with pytest.raises(ClickException, match="MOBILERUN_ANDROID_PORTAL_TOKEN"): + selection.portal_token(selected) + monkeypatch.setenv(other_name, "other-platform-token") + assert config.resolve_auth_token() == "config-token" + monkeypatch.setenv(name, "selected-platform-token") + assert config.resolve_auth_token() == "selected-platform-token" + assert config.resolve_auth_token(other) == "other-platform-token" + assert selection.portal_token(selected) == "other-platform-token" + + +@pytest.mark.parametrize("platform", ["", None, "unknown"]) +def test_invalid_platform_has_no_token(platform): + assert ( + DeviceConfig(platform=platform, auth_token="config-token").resolve_auth_token() + is None + ) + if platform is not None: + assert ( + DeviceConfig(auth_token="config-token").resolve_auth_token(platform) is None + ) + + +@pytest.mark.parametrize("platform", ["android", "ios"]) +def test_legacy_token_variable_is_ignored(monkeypatch, platform): + # This removed variable must never act as a fallback for either platform. + monkeypatch.setenv("MOBILERUN_DEVICE_TOKEN", "ignored-token") + assert DeviceConfig(platform=platform).resolve_auth_token() is None + assert ( + DeviceConfig(platform=platform, auth_token="config-token").resolve_auth_token() + == "config-token" + ) + + +def test_env_masks_both_platform_tokens(monkeypatch): + names = ("MOBILERUN_ANDROID_PORTAL_TOKEN", "MOBILERUN_IOS_PORTAL_TOKEN") + for name in names: + monkeypatch.setenv(name, "token-secret-marker") + for args in (["env"], ["env", "-j"]): + result = CliRunner().invoke(cli, args) + assert result.exit_code == 0, result.output + assert "token-secret-marker" not in result.output + assert all(name in result.stdout for name in names) + if "-j" in args: + rows = {row["variable"]: row for row in json.loads(result.stdout)} + assert all( + rows[name]["value"] == "set" and rows[name]["source"] == "env" + for name in names + ) + + +@pytest.mark.parametrize( + "platform,name,other_name", + [ + ("android", "MOBILERUN_ANDROID_PORTAL_TOKEN", "MOBILERUN_IOS_PORTAL_TOKEN"), + ("ios", "MOBILERUN_IOS_PORTAL_TOKEN", "MOBILERUN_ANDROID_PORTAL_TOKEN"), + ], +) +def test_env_uses_config_token_only_for_configured_platform(platform, name, other_name): + config = MobileConfig( + device=DeviceConfig(platform=platform, auth_token="config-secret-marker") + ) + ConfigLoader.save(config) + result = CliRunner().invoke(cli, ["env", "-j"]) + rows = {row["variable"]: row for row in json.loads(result.stdout)} + assert rows[name]["value"] == "set" + assert rows[name]["source"] == "config" + assert rows[other_name]["value"] == "not set" + assert rows[other_name]["source"] == "not set" + assert "config-secret-marker" not in result.output + + +@pytest.mark.parametrize("ios_token", [None, "ios-env-token"]) +def test_run_platform_override_does_not_reassign_android_config_token( + monkeypatch, ios_token +): + config = MobileConfig( + device=DeviceConfig(platform="android", auth_token="android-config-token") + ) + ConfigLoader.save(config) + if ios_token: + monkeypatch.setenv("MOBILERUN_IOS_PORTAL_TOKEN", ios_token) + + class Handler: + async def stream_events(self): + if False: + yield None + + def __await__(self): + async def done(): + return SimpleNamespace(success=True, reason="done", steps=1) + + return done().__await__() + + agent = Mock(return_value=SimpleNamespace(run=lambda: Handler())) + monkeypatch.setattr("mobilerun.MobileAgent", agent) + monkeypatch.setattr(run_module, "_cleanup_android_keyboard", AsyncMock()) + result = CliRunner().invoke( + cli, + [ + "run", + "task", + "-P", + "ios", + "-d", + "http://localhost:8080", + "-j", + ], + ) + assert result.exit_code == 0, result.output + resolved = agent.call_args.kwargs["config"].device + assert resolved.platform == "ios" + assert resolved.resolve_auth_token() == ios_token + assert resolved.resolve_auth_token("android") is None + + +UI_STATE = { + "a11y_tree": { + "className": "android.widget.FrameLayout", + "boundsInScreen": {"left": 0, "top": 0, "right": 100, "bottom": 200}, + "children": [ + { + "className": "android.widget.Button", + "text": "Save\tthis\nnow", + "resourceId": "app:id/save", + "isClickable": True, + "isFocused": True, + "boundsInScreen": {"left": 1, "top": 2, "right": 30, "bottom": 40}, + }, + { + "className": "android.view.View", + "children": [ + { + "class_name": "android.widget.CheckBox", + "content_description": "Accept\rterms", + "is_checkable": True, + "checked": True, + "selected": True, + "enabled": False, + "bounds": {"x": 4, "y": 5, "width": 20, "height": 10}, + }, + { + "className": "android.widget.EditText", + "resourceId": "app:id/input", + "editable": True, + }, + ], + }, + {"className": "android.widget.ScrollView", "isScrollable": True}, + ], + }, + "phone_state": {"currentApp": "Test\nApp"}, + "device_context": {"screen_bounds": {"width": 100, "height": 200}}, +} +EXPECTED_LINES = [ + "1\tButton\tSave this now\tapp:id/save\t1,2,30,40\tclickable,focused", + "3\tCheckBox\tAccept terms\t\t4,5,24,15\tcheckable,checked,selected,disabled", + "4\tEditText\t\tapp:id/input\t\teditable", + "5\tScrollView\t\t\t\tscrollable", +] +EXPECTED_ALL_LINES = [ + "0\tFrameLayout\t\t\t0,0,100,200\t", + "1\tButton\tSave this now\tapp:id/save\t1,2,30,40\tclickable,focused", + "2\tView\t\t\t\t", + "3\tCheckBox\tAccept terms\t\t4,5,24,15\tcheckable,checked,selected,disabled", + "4\tEditText\t\tapp:id/input\t\teditable", + "5\tScrollView\t\t\t\tscrollable", +] + + +@pytest.mark.parametrize( + "device_id", ["serial", "123e4567-e89b-12d3-a456-426614174000"] +) +@pytest.mark.parametrize("all_elements", [False, True]) +def test_ui_text_is_one_line_per_core_node(monkeypatch, device_id, all_elements): + import mobilerun_core + + device = Mock() + device.ui.return_value = UiState(UI_STATE) + monkeypatch.setattr(mobilerun_core.Mobilerun, "connect", lambda *a, **kw: device) + monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", "fake-key") + args = ["device", "ui", "-d", device_id] + (["--all"] if all_elements else []) + result = CliRunner().invoke(cli, args) + assert result.exit_code == 0, result.output + expected = EXPECTED_ALL_LINES if all_elements else EXPECTED_LINES + assert result.stdout.splitlines() == expected + assert all(line.count("\t") == 5 for line in result.stdout.splitlines()) + assert result.stderr == "App: Test App; screen: 100x200\n" + device.ui_json.assert_not_called() + + +def test_ui_json_remains_raw_core_state(monkeypatch): + import mobilerun_core + + device = Mock() + device.ui_json.return_value = json.dumps(UI_STATE) + monkeypatch.setattr(mobilerun_core.Mobilerun, "connect", lambda *a, **kw: device) + result = CliRunner().invoke(cli, ["device", "ui", "-d", "serial", "-j"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == UI_STATE + assert result.stderr == "" + device.ui_json.assert_called_once_with() + device.ui.assert_not_called() + + +def test_ui_uses_description_for_whitespace_and_sanitizes_every_control(monkeypatch): + import mobilerun_core + + controls = ( + "".join(chr(code) for code in (*range(32), *range(127, 160))) + + "\u2028\u2029\u200e" + ) + state = UiState( + { + "a11y_tree": { + "children": [ + { + "className": "Button", + "text": " \t\n ", + "contentDescription": "Description", + }, + { + "className": "TextView", + "text": "before" + controls + "after", + "resourceId": "res\x1b[31m", + "bounds": [1, 2, 3, 4], + }, + ] + } + } + ) + device = Mock() + device.ui.return_value = state + monkeypatch.setattr(mobilerun_core.Mobilerun, "connect", lambda *a, **kw: device) + result = CliRunner().invoke(cli, ["device", "ui", "-d", "serial"]) + assert result.exit_code == 0, result.output + assert result.stdout.splitlines() == [ + "1\tButton\tDescription\t\t\t", + "2\tTextView\tbefore" + " " * len(controls) + "after\tres [31m\t\t", + ] + + +@pytest.mark.parametrize( + "state", + [ + UiState({"a11y_tree": None, "ime_tree": [{"text": "keyboard"}]}), + SimpleNamespace(dead_tree=True), + SimpleNamespace(a11y_tree_available=False), + ], +) +def test_ui_unavailable_is_a_failure(monkeypatch, state): + import mobilerun_core + + device = Mock() + device.ui.return_value = state + monkeypatch.setattr(mobilerun_core.Mobilerun, "connect", lambda *a, **kw: device) + result = CliRunner().invoke(cli, ["device", "ui", "-d", "serial"]) + assert result.exit_code == 1 + assert result.stdout == "" + assert result.stderr == "Error: The device UI tree is unavailable.\n" + + +def test_ui_all_and_json_is_a_usage_error_before_connecting(monkeypatch): + import mobilerun_core + + connect = Mock() + monkeypatch.setattr(mobilerun_core.Mobilerun, "connect", connect) + result = CliRunner().invoke(cli, ["device", "ui", "-d", "serial", "--all", "-j"]) + assert result.exit_code == 2 + assert result.stdout == "" + assert "--all cannot be combined with --json" in result.stderr + connect.assert_not_called() + + +REQUEST_KEY = "private-key-marker" +ENV_KEY = "env-key-marker" +SDK_PATH = "/v1/devices/0python-false-1000" + + +@pytest.fixture +def sdk_error_cases(): + cases = [] + for kind, status, body, message, code, hint in [ + ( + "status", + 401, + { + "detail": "Not authorized", + "title": "Unauthorized", + "code": "unauthorized", + }, + "Not authorized", + "unauthorized", + "mobilerun auth login", + ), + ( + "status", + 403, + {"title": "Forbidden"}, + "Forbidden", + None, + "valid but not allowed", + ), + ( + "status", + 404, + {"detail": "Device not found"}, + "Device not found", + None, + "mobilerun device list -t cloud", + ), + ( + "status", + 422, + {"errors": [{"message": "Invalid device id", "code": "validation_error"}]}, + "Invalid device id", + "validation_error", + None, + ), + ("status", 500, {}, "Internal Server Error", None, None), + ( + "status", + 422, + { + "detail": f"Device 10 python false {REQUEST_KEY} {ENV_KEY}", + "code": "0_python_false", + }, + "Device 10 python false [redacted] [redacted]", + "0_python_false", + None, + ), + ( + "connection", + None, + None, + "Could not connect to the API", + None, + "https://api.example.test", + ), + ("timeout", None, None, "Request timed out", None, "https://api.example.test"), + ]: + + def respond(request, kind=kind, status=status, body=body): + if kind == "connection": + raise httpx.ConnectError( + f"raw connection {REQUEST_KEY}", request=request + ) + if kind == "timeout": + raise httpx.ReadTimeout(f"raw timeout {REQUEST_KEY}", request=request) + return httpx.Response( + status, + json={ + **body, + "raw_secret": REQUEST_KEY, + "request_headers": dict(request.headers), + }, + ) + + with Mobilerun( + api_key=REQUEST_KEY, + base_url="https://api.example.test/v1", + http_client=httpx.Client(transport=httpx.MockTransport(respond)), + max_retries=0, + ) as client: + try: + client.devices.retrieve( + "0python-false-1000", extra_query={"token": REQUEST_KEY} + ) + except (APIStatusError, APIConnectionError) as error: + assert error.request.headers["x-stainless-lang"] == "python" + assert error.request.headers["x-stainless-async"] == "false" + assert error.request.headers["x-stainless-retry-count"] == "0" + cases.append((error, message, status, code, hint)) + else: + pytest.fail("The mock SDK request must fail") + return cases + + +def invoke_api_error(monkeypatch, error, *flags): + monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", ENV_KEY) + monkeypatch.setattr(deviceauth, "load_credentials", lambda: None) + monkeypatch.setattr( + device_lifecycle, + "core_client", + lambda *a, **kw: SimpleNamespace( + list_devices=Mock(side_effect=error), + close=Mock(), + ), + ) + result = CliRunner().invoke(cli, ["device", "list", "-t", "cloud", "-v", *flags]) + assert result.exit_code == 1, result.output + for forbidden in ( + REQUEST_KEY, + ENV_KEY, + "Authorization", + "request_headers", + "raw_secret", + "Traceback", + "raw connection", + "raw timeout", + "core wrapper", + "?token", + ): + assert forbidden not in result.output + assert result.stderr.startswith(f"GET {SDK_PATH}\nError: ") + return result + + +def test_api_errors_text(monkeypatch, sdk_error_cases): + for error, message, status, _, hint in sdk_error_cases: + result = invoke_api_error(monkeypatch, error) + expected = f"GET {SDK_PATH}\nError: {message}" + if status is not None: + expected += f" (HTTP {status})" + assert result.stderr.startswith(expected + "\n") + if hint: + assert hint in result.stderr + assert result.stdout == "" + + +def test_api_errors_json(monkeypatch, sdk_error_cases): + for error, message, status, code, _ in sdk_error_cases: + result = invoke_api_error(monkeypatch, error, "-j") + expected = {"error": message, "status": status, "code": code} + if status in {401, 403}: + expected["credential"] = "env" + assert json.loads(result.stdout) == expected + + +def test_wrapped_api_error_uses_sdk_cause(monkeypatch, sdk_error_cases): + error, message, status, code, _ = sdk_error_cases[0] + wrapped = RuntimeError(f"core wrapper {REQUEST_KEY}") + wrapped.__cause__ = error + result = invoke_api_error(monkeypatch, wrapped, "-j") + assert json.loads(result.stdout) == { + "error": message, + "status": status, + "code": code, + "credential": "env", + } diff --git a/tests/test_cli_qa_fixes.py b/tests/test_cli_qa_fixes.py new file mode 100644 index 00000000..f2ffe47b --- /dev/null +++ b/tests/test_cli_qa_fixes.py @@ -0,0 +1,331 @@ +"""Regressions for CLI discovery, config refusal and early run validation.""" + +import importlib +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from async_adbutils import AdbTimeout +from click.testing import CliRunner + +from mobilerun.cli import device_lifecycle, deviceauth, selection +from mobilerun.cli.main import cli +from mobilerun.cli.options import ADB_HINT +from mobilerun.config_manager import ConfigLoader, MobileConfig + + +@pytest.fixture(autouse=True) +def isolated(monkeypatch, tmp_path): + monkeypatch.setattr( + ConfigLoader, "get_user_config_path", lambda: tmp_path / "config.yaml" + ) + monkeypatch.setattr(deviceauth, "load_credentials", lambda: None) + monkeypatch.setenv("MOBILERUN_TELEMETRY_ENABLED", "false") + for name in ( + "MOBILERUN_CONFIG", + "MOBILERUN_DEVICE", + "MOBILERUN_TARGET", + "MOBILERUN_DEBUG", + "ANDROID_SERIAL", + ): + monkeypatch.delenv(name, raising=False) + + +@pytest.mark.parametrize( + "error", + [ + ModuleNotFoundError("No module named 'adbutils'", name="adbutils"), + ModuleNotFoundError( + "No module named 'adbutils.binaries'", name="adbutils.binaries" + ), + ], +) +@pytest.mark.parametrize( + "args", + [ + ["device", "list"], + ["device", "list", "-j"], + ["device", "ui"], + ["device", "ping"], + ["device", "apps"], + ["device", "setup"], + ["doctor"], + ["doctor", "-j"], + ], +) +def test_adb_startup_failures_have_one_shared_hint(monkeypatch, error, args): + doctor = importlib.import_module("mobilerun.cli.doctor") + monkeypatch.setattr( + doctor, + "check_sdk_version", + AsyncMock( + return_value=doctor.CheckResult("SDK Version", doctor.Status.PASS, "test") + ), + ) + monkeypatch.setattr(device_lifecycle.adb, "list", AsyncMock(side_effect=error)) + # Core swallows ADB discovery failures and returns an empty local listing. + client = SimpleNamespace(list_devices=Mock(return_value=[]), close=Mock()) + monkeypatch.setattr(device_lifecycle, "core_client", lambda *a, **kw: client) + result = CliRunner().invoke(cli, args) + assert result.exit_code == 1, result.output + if args[0] == "doctor": + if "-j" in args: + checks = json.loads(result.stdout)["checks"] + assert checks[-1] == {"name": "ADB", "status": "fail", "detail": ADB_HINT} + else: + assert "ADB: fail: " + ADB_HINT in result.stdout + else: + assert result.stderr == "Error: " + ADB_HINT + "\n" + if "-j" in args: + assert json.loads(result.stdout) == {"error": ADB_HINT} + assert "No module named" not in result.output + assert "No devices found" not in result.output + + +def test_adb_translation_does_not_hide_unrelated_missing_modules(): + error = ModuleNotFoundError("missing other dependency", name="other_dependency") + with pytest.raises(ModuleNotFoundError) as raised, selection.local_adb_errors(): + raise error + assert raised.value is error + + +def test_all_devices_keeps_cloud_after_swallowed_local_adb_failure(monkeypatch): + monkeypatch.setattr( + device_lifecycle.adb, + "list", + AsyncMock(side_effect=ModuleNotFoundError(name="adbutils")), + ) + calls = [] + device = SimpleNamespace( + device_id="cloud-device", + name="phone", + state="ready", + device_type="android", + close=Mock(), + ) + + def listing(**kwargs): + calls.append(kwargs["scope"]) + return [] if kwargs["scope"] == "local" else [device] + + client = SimpleNamespace(list_devices=listing, close=Mock()) + monkeypatch.setattr(device_lifecycle, "core_client", lambda *a, **kw: client) + result = CliRunner().invoke(cli, ["device", "list", "--all", "-j"]) + assert result.exit_code == 0, result.output + assert calls == ["local", "cloud"] + document = json.loads(result.stdout) + assert document["devices"][0]["device_id"] == "cloud-device" + assert document["errors"] == [{"target": "local", "error": ADB_HINT}] + assert result.stderr == "Warning: local: " + ADB_HINT + "\n" + assert client.close.call_count == 2 + device.close.assert_called_once() + + +@pytest.mark.parametrize("command", [["device", "list"], ["macro", "list"]]) +@pytest.mark.parametrize("via_env", [False, True]) +def test_listing_config_directory_is_a_shared_usage_error( + monkeypatch, tmp_path, command, via_env +): + args = [*command, *([str(tmp_path)] if command[0] == "macro" else [])] + if via_env: + monkeypatch.setenv("MOBILERUN_CONFIG", str(tmp_path)) + else: + args.extend(["-c", str(tmp_path)]) + result = CliRunner().invoke(cli, args) + assert result.exit_code == 2, result.output + assert "is a directory" in result.stderr + assert "'-c' / '--config'" in result.stderr + assert result.stdout == "" + + +@pytest.mark.parametrize("content", ["device:\n platform: android\n", ""]) +def test_device_list_refuses_config_without_version(monkeypatch, tmp_path, content): + config = tmp_path / "old.yaml" + config.write_text(content) + monkeypatch.setenv("MOBILERUN_CONFIG", str(config)) + client = Mock() + monkeypatch.setattr(device_lifecycle, "core_client", client) + result = CliRunner().invoke(cli, ["device", "list"]) + assert result.exit_code == 1, result.output + assert "outdated (missing _version)" in result.stderr + client.assert_not_called() + + +def test_delegate_precondition_precedes_banner(): + result = CliRunner().invoke(cli, ["run", "x", "--delegate-model", "openai/x"]) + assert result.exit_code == 2, result.output + assert "--delegate-model needs a decision model" in result.stderr + assert "Try Mobilerun Cloud" not in result.output + assert "Starting:" not in result.output + assert result.stdout == "" + + +def test_delegate_precondition_uses_overridden_reasoning_role(monkeypatch): + from mobilerun.config_manager.config_manager import DecisionProfile + + config = MobileConfig() + config.decision_profiles["fast_agent"] = DecisionProfile() + monkeypatch.setattr(ConfigLoader, "load", lambda _: config) + result = CliRunner().invoke(cli, ["run", "x", "-r", "--delegate-model", "openai/x"]) + assert result.exit_code == 2, result.output + assert "--delegate-model needs a decision model" in result.stderr + assert "Starting:" not in result.output + + +@pytest.fixture +def rate_limit_server(): + import threading + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + class FakePortalAndLLM(BaseHTTPRequestHandler): + llm_requests = [] + + def log_message(self, *args): + pass + + def reply(self, value, status=200): + body = json.dumps(value).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + if status == 429: + self.send_header("Retry-After", "2") + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path.startswith("/state_full"): + self.reply(dict(a11y_tree={}, phone_state={}, device_context={})) + else: + self.reply({"version": "0.7.25"}) + + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", 0))) + if self.path.startswith("/v1/"): + self.llm_requests.append(self.path) + self.reply( + { + "error": { + "message": "persistent rate limit (429)", + "type": "rate_limit_error", + } + }, + 429, + ) + else: + self.reply({"result": 1730000000000 if self.path == "/time" else {}}) + + server = ThreadingHTTPServer(("127.0.0.1", 0), FakePortalAndLLM) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", FakePortalAndLLM.llm_requests + finally: + server.shutdown() + server.server_close() + thread.join() + + +@pytest.mark.parametrize( + "provider,model", + [("openai-like", "gpt-6.1-sol"), ("anthropic", "claude-sonnet-5-5")], +) +def test_run_persistent_429_is_bounded_and_emits_one_document( + tmp_path, rate_limit_server, provider, model +): + import os + import subprocess + import sys + + url, requests = rate_limit_server + path = ConfigLoader.save(MobileConfig(), tmp_path / "run.yaml") + env = { + key: value + for key, value in os.environ.items() + if not key.startswith(("MOBILERUN_", "DROIDRUN_", "OPENAI_", "ANTHROPIC_")) + and not key.endswith(("_API_KEY", "_TOKEN")) + } + env.update( + HOME=str(tmp_path), + MOBILERUN_TELEMETRY_ENABLED="false", + OPENAI_API_KEY="fake-local-key", + ANTHROPIC_API_KEY="fake-local-key", + MOBILERUN_ANDROID_PORTAL_TOKEN="fake-local-token", + HTTP_PROXY="http://127.0.0.1:1", + HTTPS_PROXY="http://127.0.0.1:1", + ALL_PROXY="http://127.0.0.1:1", + NO_PROXY="127.0.0.1,localhost", + ) + args = ["run", "x", "-c", str(path), "-d", url] + args += "-P android -s 3 -j".split() + args += [ + "-p", + provider, + "-m", + model, + "-u", + url + ("/v1" if provider == "openai-like" else ""), + ] + result = subprocess.run( + [sys.executable, "-c", "from mobilerun.cli.main import cli; cli()", *args], + env=env, + capture_output=True, + text=True, + timeout=25, + ) + assert result.returncode == 1, result.stdout + result.stderr + assert "429" in result.stderr + assert ( + "/v1/messages" if provider == "anthropic" else "/v1/chat/completions" + ) in requests + document = json.loads(result.stdout) + assert document["success"] is False + assert "429" in document["reason"] + + +def test_retry_defaults_cover_provider_families_and_legacy_adapters(): + from mobilerun.cli.providers import llm_retry_kwargs + + runtimes = ( + "OpenAI OpenAIResponses OpenAILike Anthropic GoogleGenAI XAI ZAI ZAI_Coding " + "MiniMax DeepSeek OpenRouter openai_oauth xai_oauth" + ) + for runtime in runtimes.split(): + assert llm_retry_kwargs(runtime) == {"max_retries": 1}, runtime + for runtime in ("Ollama", "anthropic_oauth", "gemini_oauth_code_assist"): + assert llm_retry_kwargs(runtime) == {}, runtime + + +@pytest.mark.parametrize("error", [TimeoutError(), AdbTimeout("adb read timeout")]) +def test_adb_timeouts_use_the_shared_hint(error): + import click + + with ( + pytest.raises(click.ClickException, match="Platform-Tools"), + selection.local_adb_errors(), + ): + raise error + + +def test_device_list_bounds_an_unresponsive_adb_probe(monkeypatch): + import asyncio + + cancelled = Mock() + + async def stalled(): + try: + await asyncio.sleep(10) + return [] + finally: + cancelled() + + client = SimpleNamespace(list_devices=Mock(return_value=[]), close=Mock()) + monkeypatch.setattr(device_lifecycle, "core_client", lambda *a, **kw: client) + monkeypatch.setattr(device_lifecycle.adb, "list", stalled) + result = CliRunner().invoke(cli, ["device", "list", "-j"]) + assert result.exit_code == 1, result.output + assert json.loads(result.stdout) == {"error": ADB_HINT} + assert result.stderr == "Error: " + ADB_HINT + "\n" + cancelled.assert_called_once() + client.close.assert_called_once() diff --git a/tests/test_cli_qr.py b/tests/test_cli_qr.py new file mode 100644 index 00000000..d3f29a48 --- /dev/null +++ b/tests/test_cli_qr.py @@ -0,0 +1,706 @@ +"""Terminal QR rendering and local wireless debugging at the CLI boundary.""" + +import io +import subprocess +import sys +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import click +import pytest +import segno +from click.testing import CliRunner + +from mobilerun.cli import auth, device_lifecycle, device_pair, deviceauth, qr +from mobilerun.cli.main import cli +from mobilerun.config_manager import ConfigLoader + + +class TerminalRunner(CliRunner): + @contextmanager + def isolation(self, *args, **kwargs): + with super().isolation(*args, **kwargs) as streams: + sys.stderr.isatty = lambda: True + yield streams + + +@pytest.fixture(autouse=True) +def isolated_config(monkeypatch, tmp_path): + monkeypatch.setattr( + ConfigLoader, "get_user_config_path", lambda: tmp_path / "config.yaml" + ) + for name in ("MOBILERUN_CONFIG", "MOBILERUN_DEBUG", "MOBILERUN_AUTH_URL"): + monkeypatch.delenv(name, raising=False) + + +def test_print_qr_skips_nonterminal(monkeypatch): + output = io.StringIO() + monkeypatch.setattr(qr.sys, "stderr", output) + assert qr.print_qr("https://example.test/verify") is False + assert output.getvalue() == "" + + +def test_print_qr_uses_stderr_and_preserves_quiet_zone(monkeypatch): + output = io.StringIO() + output.isatty = lambda: True + monkeypatch.setattr(qr.sys, "stderr", output) + payload = "https://example.test/verify?user_code=ABCD-EFGH" + code = segno.make_qr(payload) + make = Mock(return_value=code) + monkeypatch.setattr(segno, "make_qr", make) + assert qr.print_qr(payload) is True + make.assert_called_once_with(payload) + lines = output.getvalue().splitlines() + assert len(lines) > 10 + assert all( + line.startswith("\x1b[97;40m") and line.endswith("\x1b[0m") for line in lines + ) + # Two compact rows preserve the four-module light quiet zone. + assert lines[:2] == ["\x1b[97;40m" + "█" * code.symbol_size()[0] + "\x1b[0m"] * 2 + + +@pytest.mark.parametrize( + "error", [ValueError("cannot encode"), UnicodeError("cannot render")] +) +def test_print_qr_encoding_failure_returns_false(monkeypatch, error): + output = io.StringIO() + output.isatty = lambda: True + monkeypatch.setattr(qr.sys, "stderr", output) + monkeypatch.setattr(segno, "make_qr", Mock(side_effect=error)) + assert qr.print_qr("https://example.test/verify") is False + assert output.getvalue() == "" + + +@pytest.mark.parametrize( + "error", [OSError("cannot write"), UnicodeError("unsupported encoding")] +) +def test_print_qr_write_failure_returns_false(monkeypatch, error): + output = io.StringIO() + output.isatty = lambda: True + output.write = Mock(side_effect=error) + monkeypatch.setattr(qr.sys, "stderr", output) + assert qr.print_qr("https://example.test/verify") is False + + +@pytest.fixture +def login(monkeypatch, tmp_path): + import webbrowser + + monkeypatch.delenv("NO_COLOR", raising=False) + + link = "https://example.test/verify?user_code=ABCD-EFGH" + code = deviceauth.CodeResponse( + "device-code", "ABCD-EFGH", "https://example.test/verify", link, 120, 5 + ) + request = Mock(return_value=code) + poll = Mock(return_value="test-session") + save = Mock(return_value=tmp_path / "credentials.json") + browser = Mock() + monkeypatch.setattr(deviceauth, "request_code", request) + monkeypatch.setattr(deviceauth, "poll_token", poll) + monkeypatch.setattr(deviceauth, "save_token", save) + monkeypatch.setattr(deviceauth, "fetch_session", lambda *_: None) + monkeypatch.setattr(webbrowser, "open", browser) + printed = Mock(side_effect=qr.print_qr) + monkeypatch.setattr(auth, "print_qr", printed) + return SimpleNamespace( + code=code, + request=request, + poll=poll, + save=save, + browser=browser, + printed=printed, + ) + + +def test_login_prints_complete_link_qr_and_preserves_flow(login): + result = TerminalRunner().invoke(cli, ["auth", "login"]) + assert result.exit_code == 0, result.output + assert result.stdout == "" + assert "ABCD-EFGH" in result.stderr and "Visit:" in result.stderr + login.printed.assert_called_once_with( + "https://example.test/verify?user_code=ABCD-EFGH" + ) + assert "\x1b[97;40m" in result.stderr + login.browser.assert_not_called() + login.request.assert_called_once_with( + auth.DEFAULT_AUTH_URL, auth.DEFAULT_DEVICE_CLIENT_ID + ) + login.poll.assert_called_once_with( + auth.DEFAULT_AUTH_URL, auth.DEFAULT_DEVICE_CLIENT_ID, login.code + ) + login.save.assert_called_once_with("test-session", auth.DEFAULT_AUTH_URL) + + +def test_login_no_qr_preserves_links_and_polling(login): + result = TerminalRunner().invoke(cli, ["auth", "login", "--no-qr"]) + assert result.exit_code == 0, result.output + assert result.stdout == "" + login.printed.assert_not_called() + login.browser.assert_not_called() + login.poll.assert_called_once_with( + auth.DEFAULT_AUTH_URL, auth.DEFAULT_DEVICE_CLIENT_ID, login.code + ) + + +def test_login_without_complete_link_skips_qr_and_browser(login): + login.code.verification_uri_complete = "" + result = TerminalRunner().invoke(cli, ["auth", "login"]) + assert result.exit_code == 0, result.output + assert result.stdout == "" + login.printed.assert_not_called() + login.browser.assert_not_called() + login.poll.assert_called_once_with( + auth.DEFAULT_AUTH_URL, auth.DEFAULT_DEVICE_CLIENT_ID, login.code + ) + + +def test_login_continues_after_qr_render_failure(monkeypatch, login): + monkeypatch.setattr(segno, "make_qr", Mock(side_effect=ValueError("cannot encode"))) + result = TerminalRunner().invoke(cli, ["auth", "login"]) + assert result.exit_code == 0, result.output + assert result.stdout == "" + assert "Visit:" in result.stderr and "ABCD-EFGH" in result.stderr + login.browser.assert_not_called() + login.poll.assert_called_once_with( + auth.DEFAULT_AUTH_URL, auth.DEFAULT_DEVICE_CLIENT_ID, login.code + ) + login.save.assert_called_once_with("test-session", auth.DEFAULT_AUTH_URL) + + +@pytest.mark.parametrize("service", ["openai", "anthropic", "gemini", "xai"]) +def test_no_qr_is_not_a_provider_option(service): + result = CliRunner().invoke(cli, ["auth", "login", service, "--no-qr"]) + assert result.exit_code == 2 + assert f"--no-qr is not supported for {service}" in result.stderr + assert result.stdout == "" + + +def test_mdns_parser_reads_services_and_ignores_header(): + output = ( + "List of discovered mdns services\n" + "other-device\t_adb-tls-pairing._tcp\t192.0.2.2:30001\n" + "mobilerun-a1b2c3\t_adb-tls-pairing._tcp.\t192.0.2.1:30002\n" + "adb-device\t_adb-tls-connect._tcp\t192.0.2.1:30003\n" + "ipv6\t_adb-tls-connect._tcp\t[2001:db8::1]:30004\n" + "legacy\t_adb._tcp\t192.0.2.1:5555\n" + "broken\t_adb-tls-pairing._tcp\tnot-an-address\n" + ) + services = device_pair.parse_mdns_services(output) + assert services == [ + ("other-device", "_adb-tls-pairing._tcp", "192.0.2.2:30001"), + ("mobilerun-a1b2c3", "_adb-tls-pairing._tcp", "192.0.2.1:30002"), + ("adb-device", "_adb-tls-connect._tcp", "192.0.2.1:30003"), + ("legacy", "_adb._tcp", "192.0.2.1:5555"), + ("ipv6", "_adb-tls-connect._tcp", "[2001:db8::1]:30004"), + ] + + +@pytest.mark.parametrize( + "address", + [ + "2001:db8::1:30004", + "fe80::1%en0:30004", + "[fe80::1%en0]:30004", + "169.254.1.2:30004", + "192.0.2.1:0", + "192.0.2.1:65536", + "invalid:30004", + ], +) +def test_mdns_parser_skips_unsafe_addresses(address): + assert ( + device_pair.parse_mdns_services( + f"mobilerun-a1b2c3\t_adb-tls-pairing._tcp\t{address}" + ) + == [] + ) + + +@pytest.fixture +def pairing(monkeypatch): + printed = Mock(return_value=True) + monkeypatch.setattr(device_pair, "print_qr", printed) + clock = SimpleNamespace(now=0.0) + monkeypatch.setattr( + device_pair, + "time", + SimpleNamespace( + monotonic=lambda: clock.now, + sleep=lambda seconds: setattr(clock, "now", clock.now + seconds), + ), + ) + choices = iter("a1b2c3" + "Ab12Cd34Ef") + monkeypatch.setattr(device_pair.secrets, "choice", lambda alphabet: next(choices)) + responses = [ + (0, "mdns daemon version [Openscreen discovery 0.0.0]", ""), + ( + 0, + "other\t_adb-tls-pairing._tcp\t192.0.2.2:31001\n" + "mobilerun-a1b2c3-extra\t_adb-tls-pairing._tcp\t192.0.2.2:31002", + "", + ), + (0, "mobilerun-a1b2c3\t_adb-tls-pairing._tcp\t192.0.2.1:32001", ""), + ( + 0, + "Enter pairing code: Successfully paired to 192.0.2.1:32001 [guid=adb-device]", + "", + ), + (0, "List of devices attached\n", ""), + (0, "adb-device._adb-tls-connect._tcp\tdevice", ""), + (0, "adb-device\t_adb-tls-connect._tcp\t192.0.2.1:33002", ""), + (0, "adb-device._adb-tls-connect._tcp\tdevice", ""), + ] + calls = [] + + def run(*args, **kwargs): + calls.append((args, kwargs)) + status, out, err = responses.pop(0) + return subprocess.CompletedProcess(["adb", *args], status, out, err) + + monkeypatch.setattr(device_pair, "_run_adb", run) + return SimpleNamespace( + responses=responses, calls=calls, printed=printed, clock=clock + ) + + +@pytest.fixture +def fallback(pairing): + pairing.responses[4:] = [(0, "List of devices attached\n", "")] * 16 + [ + ( + 0, + "other-device\t_adb-tls-connect._tcp\t192.0.2.2:33001\n" + "adb-device\t_adb-tls-connect._tcp\t[2001:db8::1]:33002\n" + "adb-device\t_adb-tls-connect._tcp\t192.0.2.1:33002", + "", + ), + (0, "List of devices attached\n", ""), + (0, "connected to 192.0.2.1:33002", ""), + (0, "192.0.2.1:33002\tdevice", ""), + ] + return pairing + + +def test_adb_devices_parser_ignores_header_and_retains_states(): + assert device_pair.parse_adb_devices( + "List of devices attached\n\n" + "adb-device._adb-tls-connect._tcp\tdevice\n" + "192.0.2.2:5555\toffline\n" + "usb-device\tunauthorized\n" + "other-device\tconnecting\n" + ) == { + "adb-device._adb-tls-connect._tcp": "device", + "192.0.2.2:5555": "offline", + "usb-device": "unauthorized", + "other-device": "connecting", + } + + +def test_pair_waits_for_auto_connect_without_exposing_password(pairing): + result = TerminalRunner().invoke(cli, ["device", "pair", "-v"]) + assert result.exit_code == 0, result.output + assert result.stdout == "adb-device._adb-tls-connect._tcp\n" + assert "Paired and connected" in result.stderr + assert "mobilerun device setup -d adb-device._adb-tls-connect._tcp" in result.stderr + assert pairing.printed.call_args.args == ( + "WIFI:T:ADB;S:mobilerun-a1b2c3;P:Ab12Cd34Ef;;", + ) + assert [args for args, _ in pairing.calls] == [ + ("mdns", "check"), + ("mdns", "services"), + ("mdns", "services"), + ("pair", "192.0.2.1:32001"), + ("devices",), + ("devices",), + ("mdns", "services"), + ("devices",), + ] + assert pairing.calls[3][1]["input"] == "Ab12Cd34Ef\n" + assert all("Ab12Cd34Ef" not in " ".join(args) for args, _ in pairing.calls) + assert all( + kwargs["input"] is None for _, kwargs in pairing.calls[:3] + pairing.calls[4:] + ) + assert "Ab12Cd34Ef" not in result.output + assert pairing.clock.now == 2 + + +@pytest.mark.parametrize("state", ["offline", "unauthorized", "connecting"]) +def test_pair_waits_for_guid_transport_to_be_ready(pairing, state): + pairing.responses[4] = ( + 0, + "unrelated\tdevice\n" f"adb-device._adb-tls-connect._tcp\t{state}", + "", + ) + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 0, result.output + assert result.stdout == "adb-device._adb-tls-connect._tcp\n" + assert pairing.clock.now == 2 + assert not any(args[0] == "connect" for args, _ in pairing.calls) + + +def test_pair_fallback_uses_guid_ipv4_service_after_fifteen_seconds(fallback): + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 0, result.output + assert result.stdout == "192.0.2.1:33002\n" + assert "mobilerun device setup -d 192.0.2.1:33002" in result.stderr + assert fallback.calls[20][0] == ("mdns", "services") + assert fallback.calls[21][0] == ("devices",) + assert fallback.calls[22][0] == ("connect", "192.0.2.1:33002") + assert fallback.clock.now == 17 + + +@pytest.mark.parametrize("state", ["device", "offline", "unauthorized", "connecting"]) +def test_pair_fallback_rechecks_auto_connect_before_connecting(fallback, state): + fallback.responses[21:] = [ + (0, f"adb-device._adb-tls-connect._tcp\t{state}", ""), + (0, "adb-device._adb-tls-connect._tcp\tdevice", ""), + (0, "adb-device\t_adb-tls-connect._tcp\t192.0.2.1:33002", ""), + (0, "adb-device._adb-tls-connect._tcp\tdevice", ""), + ] + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 0, result.output + assert result.stdout == "adb-device._adb-tls-connect._tcp\n" + assert not any(args[0] == "connect" for args, _ in fallback.calls) + + +def test_pair_fallback_needs_ipv4_and_keeps_overall_deadline(fallback): + ipv6 = "adb-device\t_adb-tls-connect._tcp\t[2001:db8::1]:33002" + fallback.responses[20:] = [ + (0, ipv6, ""), + (0, "List of devices attached\n", ""), + (0, ipv6, ""), + ] + result = TerminalRunner().invoke(cli, ["device", "pair", "--timeout", "18"]) + assert result.exit_code == 1 + assert "Timed out" in result.stderr + assert result.stdout == "" + assert not any(args[0] == "connect" for args, _ in fallback.calls) + assert fallback.clock.now == 18 + + +def test_pair_disconnects_duplicate_ip_and_keeps_guid(pairing): + pairing.responses[7] = ( + 0, + "adb-device._adb-tls-connect._tcp\tdevice\n" + "192.0.2.1:33002\tdevice\n192.0.2.2:33001\tdevice", + "", + ) + pairing.responses.append((0, "disconnected 192.0.2.1:33002", "")) + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 0, result.output + assert result.stdout == "adb-device._adb-tls-connect._tcp\n" + assert "Disconnected duplicate transport 192.0.2.1:33002" in result.stderr + assert [args for args, _ in pairing.calls if args[0] == "disconnect"] == [ + ("disconnect", "192.0.2.1:33002"), + ] + + +def test_pair_cleans_up_late_auto_connect_after_fallback(fallback): + duplicates = "adb-device._adb-tls-connect._tcp\tdevice\n192.0.2.1:33002\tdevice" + fallback.responses[23:] = [ + (0, duplicates, ""), + (0, "adb-device\t_adb-tls-connect._tcp\t192.0.2.1:33002", ""), + (0, duplicates, ""), + (0, "disconnected 192.0.2.1:33002", ""), + ] + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 0, result.output + assert result.stdout == "adb-device._adb-tls-connect._tcp\n" + assert fallback.calls[-1][0] == ("disconnect", "192.0.2.1:33002") + assert "Disconnected duplicate transport" in result.stderr + + +def test_pair_requires_guid_in_success_reply(pairing): + pairing.responses[3] = ( + 0, + "Enter pairing code: Successfully paired to 192.0.2.1:32001", + "", + ) + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 1 + assert "Successfully paired to 192.0.2.1:32001" in result.stderr + assert result.stdout == "" + assert pairing.calls[-1][0] == ("pair", "192.0.2.1:32001") + + +def test_pair_prefers_ipv4_pairing_when_ipv6_is_listed_first(pairing): + pairing.responses[2] = ( + 0, + "mobilerun-a1b2c3\t_adb-tls-pairing._tcp\t[2001:db8::1]:32001\n" + "mobilerun-a1b2c3\t_adb-tls-pairing._tcp\t192.0.2.1:32001", + "", + ) + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 0, result.output + assert result.stdout == "adb-device._adb-tls-connect._tcp\n" + assert pairing.calls[3][0] == ("pair", "192.0.2.1:32001") + + +def test_pair_accepts_bracketed_global_ipv6_pairing_when_no_ipv4_exists(pairing): + pairing.responses[2] = ( + 0, + "mobilerun-a1b2c3\t_adb-tls-pairing._tcp\t[2001:db8::1]:32001", + "", + ) + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 0, result.output + assert result.stdout == "adb-device._adb-tls-connect._tcp\n" + assert pairing.calls[3][0] == ("pair", "[2001:db8::1]:32001") + + +CONNECT_REPLIES = [ + ("connected to 192.0.2.1:33002", 0), + ("already connected to 192.0.2.1:33002", 0), + ("daemon started\nconnected to 192.0.2.1:33002", 0), + ("not connected to 192.0.2.1:33002", 1), + ("error: connected to wrong service", 1), +] + + +@pytest.mark.parametrize("reply,exit_code", CONNECT_REPLIES) +def test_pair_connect_reply(reply, exit_code, fallback): + fallback.responses[22] = (0, reply, "") + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == exit_code, result.output + + +@pytest.mark.parametrize("reply,exit_code", CONNECT_REPLIES) +def test_device_connect_reply(monkeypatch, reply, exit_code): + monkeypatch.setattr(device_lifecycle.adb, "connect", AsyncMock(return_value=reply)) + result = CliRunner().invoke(cli, ["device", "connect", "192.0.2.1:33002"]) + assert result.exit_code == exit_code, result.output + + +def test_pair_fails_if_qr_cannot_be_rendered(monkeypatch, pairing): + monkeypatch.setattr(device_pair, "print_qr", qr.print_qr) + monkeypatch.setattr(segno, "make_qr", Mock(side_effect=ValueError("cannot encode"))) + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 1 + assert "Could not display the pairing QR code." in result.stderr + assert result.stdout == "" + assert [args for args, _ in pairing.calls] == [("mdns", "check")] + + +@pytest.mark.parametrize("response_count,limit", [(2, 1), (5, 2)]) +def test_pair_timeout(pairing, response_count, limit): + pairing.responses[:] = pairing.responses[:response_count] + result = TerminalRunner().invoke(cli, ["device", "pair", "--timeout", str(limit)]) + assert result.exit_code == 1 + assert "Timed out" in result.stderr + assert result.stdout == "" + assert pairing.clock.now == limit + + +@pytest.mark.parametrize( + "scenario,stage,index,message,expected", + [ + ("pairing", "pair", 3, "Failed pair: Ab12Cd34Ef", "Failed pair: [redacted]"), + ( + "fallback", + "connect", + 22, + "Failed connect: refused", + "Failed connect: refused", + ), + ], +) +@pytest.mark.parametrize("returncode", [0, 1]) +def test_pair_adb_failure_redacts_password( + request, scenario, stage, index, message, expected, returncode +): + pairing = request.getfixturevalue(scenario) + pairing.responses[index] = (returncode, message, "") + result = TerminalRunner().invoke(cli, ["device", "pair", "-v"]) + assert result.exit_code == 1 + assert expected in result.stderr + assert "Ab12Cd34Ef" not in result.output + assert result.stdout == "" + assert pairing.calls[-1][0][0] == stage + + +def test_pair_requires_mdns(pairing): + pairing.responses[0] = (0, "ERROR: mdns discovery disabled", "") + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 1 + assert "mDNS is unavailable" in result.stderr + pairing.printed.assert_not_called() + + +def test_pair_reports_mdns_adb_failure(pairing): + pairing.responses[0] = (1, "", "adb: mDNS discovery is disabled") + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 1 + assert "adb: mDNS discovery is disabled" in result.stderr + pairing.printed.assert_not_called() + + +def test_pair_bounds_adb_calls_by_timeout(monkeypatch, pairing): + run = Mock(side_effect=subprocess.TimeoutExpired(["adb", "mdns", "check"], 1)) + monkeypatch.setattr(device_pair, "_run_adb", run) + result = TerminalRunner().invoke(cli, ["device", "pair", "--timeout", "1"]) + assert result.exit_code == 1 + assert "Timed out" in result.stderr + assert run.call_args.kwargs["timeout"] == 1 + + +def test_pair_requires_adb(monkeypatch, pairing): + monkeypatch.setattr( + device_pair, + "_run_adb", + Mock(side_effect=FileNotFoundError(2, "missing", "/fake/adb")), + ) + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 1 + assert "adb was not found or is not running" in result.stderr + assert not pairing.calls + + +def test_pair_requires_terminal(pairing): + result = CliRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 2 + assert "needs a terminal" in result.stderr + assert not pairing.calls + pairing.printed.assert_not_called() + + +@pytest.mark.parametrize("seconds", ["0", "-1", "nan", "inf"]) +def test_pair_timeout_must_be_positive(seconds): + result = CliRunner().invoke(cli, ["device", "pair", "--timeout", seconds]) + assert result.exit_code == 2 + + +def test_pair_interrupt_exits_130(monkeypatch, pairing): + monkeypatch.setattr(device_pair, "_run_adb", Mock(side_effect=KeyboardInterrupt)) + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 130 + assert result.stdout == "" + + +def test_adb_wrapper_passes_password_only_on_stdin(monkeypatch): + run = Mock(return_value=subprocess.CompletedProcess([], 0, "paired", "")) + monkeypatch.setattr(device_pair.subprocess, "run", run) + device_pair._run_adb("pair", "192.0.2.1:32001", input="test-password\n", timeout=3) + run.assert_called_once_with( + ["adb", "pair", "192.0.2.1:32001"], + input="test-password\n", + capture_output=True, + text=True, + timeout=3, + check=False, + ) + + +@pytest.mark.parametrize( + "args,browser", [([], False), (["--browser"], True), (["--no-browser"], False)] +) +def test_mobilerun_browser_is_opt_in(login, args, browser, monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + result = TerminalRunner().invoke(cli, ["auth", "login", *args]) + assert result.exit_code == 0, result.output + assert result.stdout == "" + link = login.code.verification_uri_complete + hyperlink = f"\x1b]8;;{link}\x1b\\{link}\x1b]8;;\x1b\\\n" + assert hyperlink in result.stderr + qr_end = result.stderr.rindex("\x1b[0m") + link_position = result.stderr.index(hyperlink) + manual_position = result.stderr.index("Visit: https://example.test/verify\n") + code_position = result.stderr.index("Code: ABCD-EFGH\n") + waiting_position = result.stderr.index( + "Waiting for authorization (expires in 120s)" + ) + assert qr_end < link_position < manual_position < code_position < waiting_position + if browser: + login.browser.assert_called_once_with(link) + else: + login.browser.assert_not_called() + login.poll.assert_called_once_with( + auth.DEFAULT_AUTH_URL, auth.DEFAULT_DEVICE_CLIENT_ID, login.code + ) + login.save.assert_called_once_with("test-session", auth.DEFAULT_AUTH_URL) + + +@pytest.mark.parametrize( + "terminal,no_color,no_qr", + [ + (False, False, False), + (False, True, False), + (True, True, False), + (True, False, True), + (True, True, True), + ], +) +def test_mobilerun_plain_output_and_no_qr( + login, monkeypatch, terminal, no_color, no_qr +): + if no_color: + monkeypatch.setenv("NO_COLOR", "1") + else: + monkeypatch.delenv("NO_COLOR", raising=False) + runner = TerminalRunner() if terminal else CliRunner() + result = runner.invoke(cli, ["auth", "login", *(["--no-qr"] if no_qr else [])]) + assert result.exit_code == 0, result.output + assert result.stdout == "" + link = login.code.verification_uri_complete + assert link in result.stderr + assert result.stderr.index(link) < result.stderr.index("Code: ABCD-EFGH") + if not terminal or no_color: + plain = result.stderr.replace("\x1b[97;40m", "").replace("\x1b[0m", "") + assert "\x1b" not in plain + assert link + "\n" in result.stderr + if not terminal or no_qr: + login.printed.assert_not_called() + else: + login.printed.assert_called_once_with(link) + login.browser.assert_not_called() + + +def test_complete_login_link_is_not_wrapped_or_markup(login): + link = "https://example.test/verify?user_code=ABCD-EFGH&redirect=" + "a" * 180 + login.code.verification_uri_complete = link + result = CliRunner().invoke(cli, ["auth", "login"]) + assert result.exit_code == 0 + assert result.stdout == "" + assert result.stderr.splitlines()[0] == link + assert result.stderr.splitlines()[1:3] == [ + "Visit: https://example.test/verify", + "Code: ABCD-EFGH", + ] + login.browser.assert_not_called() + + +@pytest.mark.parametrize("service", ["openai", "anthropic", "gemini", "xai"]) +def test_browser_flag_is_mobilerun_only(service, login): + result = CliRunner().invoke(cli, ["auth", "login", service, "--browser"]) + assert result.exit_code == 2 + assert f"--browser is not supported for {service}" in result.stderr + login.browser.assert_not_called() + login.request.assert_not_called() + + +@pytest.mark.parametrize("no_browser", [False, True]) +def test_provider_browser_behavior_unchanged(monkeypatch, no_browser): + callback = Mock() + monkeypatch.setattr(auth, "_run_openai_oauth_login", callback) + result = CliRunner().invoke( + cli, ["auth", "login", "openai", *(["--no-browser"] if no_browser else [])] + ) + assert result.exit_code == 0, result.output + assert callback.call_args.kwargs["open_browser"] is (not no_browser) + + +def test_no_color_login_keeps_scannable_qr(login, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + command = click.command()( + lambda: auth._login_mobilerun( + auth.DEFAULT_AUTH_URL, + auth.DEFAULT_DEVICE_CLIENT_ID, + False, + False, + ) + ) + result = TerminalRunner().invoke(command) + assert result.exit_code == 0, result.output + assert "\x1b[97;40m" in result.stderr + plain = result.stderr.replace("\x1b[97;40m", "").replace("\x1b[0m", "") + assert "\x1b" not in plain diff --git a/tests/test_cli_runtime_policy.py b/tests/test_cli_runtime_policy.py new file mode 100644 index 00000000..7641d1e8 --- /dev/null +++ b/tests/test_cli_runtime_policy.py @@ -0,0 +1,347 @@ +"""Literal regressions from the independent CLI runtime verification.""" + +import importlib +import json +import logging +import os +import select +import signal +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import click +import httpx +import pytest +from click.testing import CliRunner + +from mobilerun.cli import device_lifecycle, deviceauth, options +from mobilerun.cli.main import cli +from mobilerun.config_manager import ConfigLoader + + +@pytest.fixture(autouse=True) +def isolated(monkeypatch, tmp_path): + monkeypatch.setattr( + ConfigLoader, "get_user_config_path", lambda: tmp_path / "config.yaml" + ) + monkeypatch.setattr(deviceauth, "load_credentials", lambda: None) + for name in ( + "MOBILERUN_CONFIG", + "MOBILERUN_DEBUG", + "MOBILERUN_CLOUD_API_KEY", + "MOBILERUN_DEVICE", + "MOBILERUN_TARGET", + ): + monkeypatch.delenv(name, raising=False) + + +@pytest.mark.parametrize( + "error", + [ + FileNotFoundError(2, "binary missing", "/fake/adb"), + ConnectionRefusedError("server unavailable after startup"), + ], +) +@pytest.mark.parametrize( + "args", + [ + ["device", "list"], + ["device", "ui"], + ["device", "ping"], + ["device", "apps"], + ["device", "tap", "1", "2", "-d", "serial"], + ["device", "setup"], + ["device", "connect", "127.0.0.1:5555"], + ["device", "disconnect", "127.0.0.1:5555"], + ], +) +def test_local_adb_missing_or_server_unreachable(monkeypatch, error, args): + import mobilerun_core + + monkeypatch.setattr("mobilerun.cli.selection.resolve_cloud_api_key", lambda: None) + monkeypatch.setattr( + "mobilerun.agent.utils.android_device.resolve_android_serial", + AsyncMock(side_effect=error), + ) + client = SimpleNamespace( + list_devices=Mock(side_effect=error), + connect=Mock(side_effect=error), + close=Mock(), + ) + monkeypatch.setattr(mobilerun_core, "Mobilerun", lambda **kwargs: client) + monkeypatch.setattr(device_lifecycle.adb, "connect", AsyncMock(side_effect=error)) + monkeypatch.setattr( + device_lifecycle.adb, "disconnect", AsyncMock(side_effect=error) + ) + result = CliRunner().invoke(cli, args) + assert result.exit_code == 1, result.output + assert result.stderr == "Error: " + options.ADB_HINT + "\n" + + +def test_local_adb_server_can_start_on_first_use(monkeypatch, tmp_path): + binary = tmp_path / "adb" + binary.write_text("#!/bin/sh\nexit 0\n") + binary.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.setenv("ANDROID_ADB_SERVER_PORT", "5999") + # The library owns startup: reaching list() must be allowed on a closed port. + listing = AsyncMock(return_value=[]) + monkeypatch.setattr(device_lifecycle.adb, "list", listing) + client = SimpleNamespace(list_devices=Mock(return_value=[]), close=Mock()) + monkeypatch.setattr(device_lifecycle, "core_client", lambda *args, **kwargs: client) + result = CliRunner().invoke(cli, ["device", "list"]) + assert result.exit_code == 0, result.output + assert options.ADB_HINT not in result.output + listing.assert_awaited_once() + + +def test_all_keeps_cloud_when_adb_fails(monkeypatch): + row = SimpleNamespace( + device_id="cloud-id", + name="phone", + state="running", + device_type="android", + close=Mock(), + ) + monkeypatch.setattr( + device_lifecycle, + "core_client", + lambda *a, **k: SimpleNamespace( + list_devices=Mock( + side_effect=lambda **kwargs: ( + [row] + if kwargs["scope"] == "cloud" + else (_ for _ in ()).throw(ConnectionRefusedError()) + ) + ), + close=Mock(), + ), + ) + result = CliRunner().invoke(cli, ["device", "list", "--all", "-j"]) + assert result.exit_code == 0, result.output + body = json.loads(result.stdout) + assert body["devices"][0]["device_id"] == "cloud-id" + assert body["errors"] == [{"target": "local", "error": options.ADB_HINT}] + assert result.stderr == "Warning: local: " + options.ADB_HINT + "\n" + + +@pytest.mark.parametrize( + "path", + [ + ["configure", "-p", "openai", "-m", "M", "-k", "-"], + ["auth", "login", "anthropic", "--token", "-"], + ], +) +def test_hidden_secret_prompt_interrupt_is_130(monkeypatch, path): + class TerminalRunner(CliRunner): + @contextmanager + def isolation(self, *args, **kwargs): + with super().isolation(*args, **kwargs) as streams: + sys.stdin.isatty = lambda: True + yield streams + + def interrupt(*a, **k): + try: + raise KeyboardInterrupt + except KeyboardInterrupt as exc: + raise click.Abort from exc + + monkeypatch.setattr(click, "prompt", interrupt) + result = TerminalRunner().invoke(cli, path) + assert result.exit_code == 130, result.output + assert "Aborted!" not in result.output + + +@pytest.mark.parametrize( + "env,flag,expected", + [ + (None, False, True), + ("false", False, False), + ("false", True, True), + ("true", False, True), + ], +) +def test_config_debug_precedence_and_env_agree(monkeypatch, env, flag, expected): + ConfigLoader.get_user_config_path().write_text( + "_version: 7\nlogging:\n debug: true\n" + ) + if env is not None: + monkeypatch.setenv("MOBILERUN_DEBUG", env) + + def probe(params): + logging.getLogger("mobilerun").debug("debug-policy-proof") + + monkeypatch.setattr(device_lifecycle, "probe_device", probe) + result = CliRunner().invoke(cli, ["device", "ping", *(["-v"] if flag else [])]) + assert result.exit_code == 0, result.output + assert ("debug-policy-proof" in result.stderr) == expected + result = CliRunner().invoke(cli, ["env", "-j", *(["-v"] if flag else [])]) + rows = {row["variable"]: row for row in json.loads(result.stdout)} + assert rows["MOBILERUN_DEBUG"]["value"] is expected + + +@pytest.mark.parametrize( + "flag,replacement,path", + [ + ("--base_url", "-u/--llm-url", ["run"]), + ("--api_base", "-u/--llm-url", ["run"]), + ("--cloud", "-t cloud", ["run"]), + ("--ios", "-P ios", ["run"]), + ("--debug", "-v", ["run"]), + ("--device-id", "-d", ["run"]), + ("--steps", "-s/--max-steps", ["run"]), + ("--path", "--apk", ["device", "setup"]), + ("-pv", "--portal-version", ["device", "setup"]), + ("--control-backend", "--backend", ["run"]), + ], +) +def test_removed_flag_guidance(flag, replacement, path): + result = CliRunner().invoke(cli, [*path, flag, "value"]) + assert result.exit_code == 2 + assert f"Removed option: {flag}. Use {replacement} instead." in result.stderr + assert "Did you mean" not in result.output + + +@pytest.mark.parametrize("blocked", ["click"]) +def test_startup_sigint_clean_subprocess(tmp_path, blocked): + # Test-only import interception provides a deterministic early SIGINT window. + # No production hook and no timing-based sleeps. + hook = tmp_path / "sitecustomize.py" + hook.write_text("""import builtins, sys, time +original = builtins.__import__ +def blocked_import(name, *args, **kwargs): + if name == %r: + print("READY", flush=True) + while True: + time.sleep(60) + return original(name, *args, **kwargs) +builtins.__import__ = blocked_import +""" % blocked) + env = {**os.environ, "PYTHONPATH": str(tmp_path)} + process = subprocess.Popen( + [sys.executable, "-c", "from mobilerun.cli.main import cli; cli()"], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + assert select.select([process.stdout], [], [], 10)[ + 0 + ], "startup hook not reached" + assert process.stdout.readline() == "READY\n" + process.send_signal(signal.SIGINT) + stdout, stderr = process.communicate(timeout=10) + assert process.returncode == 130, stderr + assert stdout == stderr == "" + finally: + if process.poll() is None: + process.kill() + process.communicate() + + +def test_status_no_credential_and_logout_env(monkeypatch): + result = CliRunner().invoke(cli, ["auth", "status"]) + assert result.exit_code == 0 + assert ( + "cloud commands have no credential; run `mobilerun auth login` or set MOBILERUN_API_KEY" + in result.stdout + ) + assert "use not set" not in result.stdout + monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", "fake-env-key") + monkeypatch.setattr(deviceauth, "clear_credentials", lambda: False) + result = CliRunner().invoke(cli, ["auth", "logout"]) + assert result.exit_code == 0 + assert "MOBILERUN_CLOUD_API_KEY stays active; unset it" in result.stderr + + +def test_short_token_does_not_redact_words(): + result = CliRunner().invoke( + cli, ["auth", "login", "xai", "--device-code", "--token", "x"] + ) + assert result.exit_code == 2 + assert "--token is not supported for xai" in result.stderr + assert "[redacted]" not in result.output + assert options.redact("abcdefgh xai", ["abcdefgh", "x"]) == "[redacted] xai" + + +def test_auth_connection_hint(monkeypatch): + monkeypatch.setattr( + deviceauth, "request_code", Mock(side_effect=httpx.ConnectError("refused")) + ) + result = CliRunner().invoke( + cli, ["auth", "login", "--auth-url", "http://127.0.0.1:9/auth"] + ) + assert result.exit_code == 1 + assert ( + "Check --auth-url or MOBILERUN_AUTH_URL: http://127.0.0.1:9/auth" + in result.stderr + ) + + +@pytest.mark.parametrize( + "args", [["device", "tap", "-5", "10"], ["device", "type", "-hello"]] +) +def test_leading_dash_hint(args): + result = CliRunner().invoke(cli, args) + assert result.exit_code == 2 + assert "Put `--` before arguments that start with a dash." in result.stderr + + +def test_typo_keeps_remaining_command(): + result = CliRunner().invoke(cli, ["devcie", "list"]) + assert result.exit_code == 2 + assert "Did you mean 'mobilerun device list'?" in result.stderr + + +def test_readme_real_error_examples(): + readme = Path("README.md").read_text() + assert '{"error":"Resource not found","status":404,"code":null}' in readme + assert '`"credential":"env"`' in readme and '`"credential":"login"`' in readme + document = json.loads( + readme.split("`doctor -j`", 1)[1].split("```json\n", 1)[1].split("\n```", 1)[0] + ) + assert [check["name"] for check in document["checks"]] == [ + "SDK Version", + "Config", + "ADB", + ] + assert document["checks"][-1]["detail"] == options.ADB_HINT + + +def test_doctor_reports_library_adb_error(monkeypatch): + import asyncio + + doctor = importlib.import_module("mobilerun.cli.doctor") + monkeypatch.setattr( + doctor.adb, + "list", + AsyncMock(side_effect=RuntimeError("library startup failed")), + ) + result = asyncio.run(doctor.check_adb(False)) + assert result.status == doctor.Status.FAIL + assert result.message == "library startup failed" + + +@pytest.mark.parametrize( + "args", + [ + ["device", "type", "--", "--debug"], + ["device", "type", "--device", "--debug", "text"], + ], +) +def test_removed_flag_spelling_can_be_a_value(monkeypatch, args): + from mobilerun.cli import device_commands + + @contextmanager + def driver(params): + yield Mock() + + monkeypatch.setattr(device_commands, "device_driver", driver) + result = CliRunner().invoke(cli, args) + assert result.exit_code == 0, result.output + assert "Removed option" not in result.output diff --git a/tests/test_cli_startup.py b/tests/test_cli_startup.py new file mode 100644 index 00000000..5fc64f60 --- /dev/null +++ b/tests/test_cli_startup.py @@ -0,0 +1,205 @@ +"""Fresh-interpreter public imports and CLI startup boundaries.""" + +import itertools +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +PUBLIC_EXPORTS = { + "mobilerun": [ + "MobileAgent", + "MobileAgentState", + "load_llm", + "load_decision_model", + "ResultEvent", + "DeviceDriver", + "AndroidDriver", + "RecordingDriver", + "MacroPlayer", + "replay_macro_file", + "replay_macro_folder", + "MobileConfig", + "AgentConfig", + "FastAgentConfig", + "ManagerConfig", + "ExecutorConfig", + "AppCardConfig", + "DeviceConfig", + "LoggingConfig", + "TracingConfig", + "TelemetryConfig", + "ToolsConfig", + "CredentialsConfig", + "LLMProfile", + "DecisionProfile", + "DecisionConfig", + "DelegationConfig", + ], + "mobilerun.agent": ["ResultEvent"], + "mobilerun.agent.droid": [ + "MobileAgent", + "MobileAgentState", + "DroidAgent", + "DroidAgentState", + ], + "mobilerun.agent.utils": [ + "to_chat_messages", + "has_content", + "filter_empty_messages", + "limit_history", + "PromptResolver", + "build_tool_registry", + "Trajectory", + ], + "mobilerun.config_manager": [ + "MobileConfig", + "DroidConfig", + "LLMProfile", + "DecisionProfile", + "DecisionConfig", + "DelegationConfig", + "AgentConfig", + "FastAgentConfig", + "ManagerConfig", + "ExecutorConfig", + "AppCardConfig", + "DeviceConfig", + "TelemetryConfig", + "TracingConfig", + "LoggingConfig", + "ToolsConfig", + "CredentialsConfig", + "ConfigLoader", + "OutdatedConfigError", + "PathResolver", + "PromptLoader", + ], +} +BANNED = ( + "llama_index", + "mobilerun.agent.droid", + "mobilerun_sdk", + "mobilerun_core", + "segno", +) +IMPORTS = ( + "from mobilerun import MobileAgent", + "from mobilerun import MobileConfig", + "import mobilerun.config_manager", + "import mobilerun; mobilerun.tools.AndroidDriver", + "from mobilerun.agent import ResultEvent", + "from mobilerun.agent.droid import MobileAgent", + "import mobilerun.macro", + "from mobilerun.cli.main import cli, run_command", + "from droidrun import MobileAgent", +) + + +def fresh(code, *args, env=None): + child_env = dict(os.environ) + child_env.pop("MOBILERUN_CLOUD_API_KEY", None) + child_env.update( + MOBILERUN_TELEMETRY_ENABLED="false", + NO_COLOR="1", + PYTHONPATH=os.pathsep.join((str(ROOT / "compat"), str(ROOT))), + ) + child_env.update(env or {}) + result = subprocess.run( + [sys.executable, *args, "-c", code], + cwd=ROOT, + env=child_env, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=20, + ) + assert result.returncode == 0, result.stdout + result.stderr + return result + + +@pytest.mark.parametrize( + "module,name", + [(mod, name) for mod, names in PUBLIC_EXPORTS.items() for name in names], +) +def test_public_export_imports_in_isolation(module, name): + fresh(f"from {module} import {name}; assert {name} is not None") + + +@pytest.mark.parametrize("first,second", list(itertools.permutations(IMPORTS, 2))) +def test_public_import_order(first, second): + fresh(first + "; " + second) + + +@pytest.mark.parametrize("module,names", PUBLIC_EXPORTS.items()) +def test_public_names_and_dir_are_preserved(module, names): + fresh( + f"import importlib; m=importlib.import_module({module!r}); " + f"assert m.__all__ == {names!r}; assert set({names!r}) <= set(dir(m))" + ) + + +@pytest.mark.parametrize( + "name,canonical", + [ + ("DroidAgent", "MobileAgent"), + ("DroidAgentState", "MobileAgentState"), + ("DroidConfig", "MobileConfig"), + ], +) +def test_legacy_root_exports(name, canonical): + fresh( + f"import mobilerun; assert getattr(mobilerun,{name!r}) is getattr(mobilerun,{canonical!r})" + ) + + +@pytest.mark.parametrize( + "args", [[], ["--help"], ["--version"], ["device", "--help"], ["completion", "zsh"]] +) +def test_cli_fast_import_boundary(args): + fresh(f"""import sys +from click.testing import CliRunner +from mobilerun.cli.main import cli, run_command +if {args!r}: + result=CliRunner().invoke(cli,{args!r}) + assert result.exit_code == 0,result.output +loaded=[name for name in sys.modules if any(name==p or name.startswith(p+'.') for p in {BANNED!r})] +assert not loaded,loaded +""") + + +def test_shell_completion_fast_import_boundary(): + fresh( + f"""import sys +from mobilerun.cli.main import cli +try: + cli.main(prog_name='mobilerun',args=[]) +except SystemExit as exc: + assert exc.code == 0,exc.code +loaded=[name for name in sys.modules if any(name==p or name.startswith(p+'.') for p in {BANNED!r})] +assert not loaded,loaded +""", + env={ + "_MOBILERUN_COMPLETE": "bash_complete", + "COMP_WORDS": "mobilerun device l", + "COMP_CWORD": "2", + }, + ) + + +@pytest.mark.parametrize( + "module", ["mobilerun", "droidrun", "mobilerun.macro", "droidrun.macro"] +) +def test_module_entrypoint_help(module): + fresh( + f"import runpy,sys; sys.argv=[{module!r},'--help']; runpy.run_module({module!r},run_name='__main__')" + ) + + +def test_compat_cli_entrypoint(): + fresh( + "import sys; from droidrun.cli_shim import main; sys.argv=['droidrun','--help']; main()" + ) diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 75fb7ece..83d92535 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -1,9 +1,12 @@ -import asyncio from types import SimpleNamespace +from unittest.mock import Mock +import mobilerun_core +import pytest from click.testing import CliRunner -from mobilerun.cli import device_commands +import mobilerun.config_manager +from mobilerun.cli import device_commands, selection from mobilerun.cli.main import cli @@ -21,7 +24,7 @@ def test_resolve_cloud_api_key_prefers_new_env(monkeypatch, tmp_path): monkeypatch.setenv("MOBILERUN_API_KEY", "new-token") monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", "old-token") - assert device_commands.resolve_cloud_api_key() == "new-token" + assert selection.resolve_cloud_api_key() == "new-token" def test_resolve_cloud_api_key_accepts_old_env(monkeypatch, tmp_path): @@ -29,7 +32,7 @@ def test_resolve_cloud_api_key_accepts_old_env(monkeypatch, tmp_path): monkeypatch.delenv("MOBILERUN_API_KEY", raising=False) monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", "old-token") - assert device_commands.resolve_cloud_api_key() == "old-token" + assert selection.resolve_cloud_api_key() == "old-token" def test_resolve_cloud_api_key_empty_new_env_falls_back(monkeypatch, tmp_path): @@ -37,7 +40,17 @@ def test_resolve_cloud_api_key_empty_new_env_falls_back(monkeypatch, tmp_path): monkeypatch.setenv("MOBILERUN_API_KEY", "") monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", "old-token") - assert device_commands.resolve_cloud_api_key() == "old-token" + assert selection.resolve_cloud_api_key() == "old-token" + + +def test_resolve_cloud_api_key_reads_primary_env(monkeypatch, tmp_path): + from mobilerun.config_manager import credential_paths + + monkeypatch.setattr(credential_paths, "OAUTH_CREDENTIAL_DIR", tmp_path) + monkeypatch.delenv("MOBILERUN_CLOUD_API_KEY", raising=False) + monkeypatch.setenv("MOBILERUN_API_KEY", "primary-token") + + assert selection.resolve_cloud_api_key() == "primary-token" def test_resolve_cloud_api_key_reads_saved_credential(monkeypatch, tmp_path): @@ -50,138 +63,136 @@ def test_resolve_cloud_api_key_reads_saved_credential(monkeypatch, tmp_path): '{"access_token": "saved-token", "auth_url": "https://example.test"}' ) - assert device_commands.resolve_cloud_api_key() == "saved-token" - - -def test_cloud_uuid_auto_routes_only_with_cloud_credential(monkeypatch): - uuid = "123e4567-e89b-12d3-a456-426614174000" - cloud_calls = [] - android_calls = [] - - class FakeCloudDriver: - def __init__(self, **kwargs): - cloud_calls.append(kwargs) - - async def connect(self): - pass + assert selection.resolve_cloud_api_key() == "saved-token" - class FakeAndroidDriver: - def __init__(self, serial, use_tcp, portal_mode="auto"): - android_calls.append((serial, use_tcp, portal_mode)) - - async def connect(self): - pass - monkeypatch.setattr(device_commands, "resolve_cloud_api_key", lambda: "cloud-token") - monkeypatch.setattr( - "mobilerun_core_local.driver.cloud.CloudDriver", - FakeCloudDriver, - ) - driver, is_ios = asyncio.run( - device_commands._create_driver(uuid, None, None, False) - ) +def test_cloud_uuid_requires_credential_without_falling_back_to_adb(monkeypatch): + import click + import pytest - assert isinstance(driver, FakeCloudDriver) - assert is_ios is False - assert cloud_calls[0]["device_id"] == uuid - assert cloud_calls[0]["api_key"] == "cloud-token" + from mobilerun.cli import selection + from mobilerun.config_manager import MobileConfig - cloud_calls.clear() - monkeypatch.setattr(device_commands, "resolve_cloud_api_key", lambda: None) monkeypatch.setattr( - device_commands.ConfigLoader, - "load", - lambda _path: SimpleNamespace( - device=SimpleNamespace( - serial="local-serial", - use_tcp=False, - platform="android", - auto_setup=False, - portal_mode="auto", - ) - ), + mobilerun.config_manager.ConfigLoader, "load", lambda _: MobileConfig() ) - monkeypatch.setattr(device_commands, "AndroidDriver", FakeAndroidDriver) - driver, is_ios = asyncio.run( - device_commands._create_driver(uuid, None, None, False) - ) - - assert isinstance(driver, FakeAndroidDriver) - assert is_ios is False - assert cloud_calls == [] - assert android_calls[-1] == (uuid, False, "auto") + monkeypatch.setattr(selection, "resolve_cloud_api_key", lambda: None) + with pytest.raises(click.ClickException, match="No cloud API key"): + with device_commands.device_driver( + { + "device": "123e4567-e89b-12d3-a456-426614174000", + "api_url": "https://example.test/v1", + } + ): + pass def test_devices_cloud_missing_key_exits_nonzero(monkeypatch): - monkeypatch.setattr("mobilerun.cli.main.resolve_cloud_api_key", lambda: None) + monkeypatch.setattr(selection, "resolve_cloud_api_key", lambda: None) - result = CliRunner().invoke(cli, ["devices", "--cloud"]) + result = CliRunner().invoke(cli, ["device", "list", "-t", "cloud"]) assert result.exit_code != 0 assert "MOBILERUN_API_KEY" in result.output -def test_devices_cloud_sdk_error_exits_nonzero(monkeypatch): - class FakeDevices: - async def list(self): - raise RuntimeError("boom") - - class FakeClient: - def __init__(self, **_kwargs): - self.devices = FakeDevices() - - monkeypatch.setattr("mobilerun.cli.main.resolve_cloud_api_key", lambda: "token") - monkeypatch.setattr("mobilerun_sdk.AsyncMobilerun", FakeClient) - - result = CliRunner().invoke(cli, ["devices", "--cloud"]) - - assert result.exit_code != 0 - assert "Error listing cloud devices" in result.output - assert "boom" in result.output - - -def test_devices_cloud_empty_list_is_success(monkeypatch): - class FakeResponse: - items = [] - - class FakeDevices: - async def list(self): - return FakeResponse() - - class FakeClient: - def __init__(self, **_kwargs): - self.devices = FakeDevices() +def test_cloud_facade_receives_url_key_and_id(monkeypatch): + from mobilerun.cli import selection + from mobilerun.config_manager import MobileConfig - monkeypatch.setattr("mobilerun.cli.main.resolve_cloud_api_key", lambda: "token") - monkeypatch.setattr("mobilerun_sdk.AsyncMobilerun", FakeClient) + calls = [] - result = CliRunner().invoke(cli, ["devices", "--cloud"]) + class FakeCore: + def close(self): + pass - assert result.exit_code == 0 - assert "No cloud devices found" in result.output + def __init__(self, **kwargs): + calls.append(kwargs) + def connect(self, device_id, **kwargs): + calls.append({"device_id": device_id, **kwargs}) + return SimpleNamespace(close=Mock()) -def test_devices_plain_keeps_local_listing_when_cloud_errors(monkeypatch): - class FakeAdbDevice: - serial = "emulator-5554" + monkeypatch.setattr( + mobilerun.config_manager.ConfigLoader, "load", lambda _: MobileConfig() + ) + monkeypatch.setattr(mobilerun_core, "Mobilerun", FakeCore) + monkeypatch.setattr(selection, "resolve_cloud_api_key", lambda: "test-key") + device_id = "123e4567-e89b-12d3-a456-426614174000" + with device_commands.device_driver( + {"device": device_id, "api_url": "https://example.test/v1"} + ): + pass + assert calls[0] == {"bearer": "test-key", "base_url": "https://example.test/v1"} + assert calls[1]["device_id"] == device_id + assert calls[1]["backend"] == "cloud" + + +def test_cloud_listing_errors_are_failures(monkeypatch): + from mobilerun.cli import device_lifecycle + + class FakeCore: + def close(self): + pass - class FakeDevices: - async def list(self): + def list_devices(self, **kwargs): + assert kwargs["scope"] in {"cloud", "all"} raise RuntimeError("cloud broke") - class FakeClient: - def __init__(self, **_kwargs): - self.devices = FakeDevices() + monkeypatch.setattr(device_lifecycle, "core_client", lambda *_, **kw: FakeCore()) + for flags in (["-t", "cloud"], ["--all"]): + result = CliRunner().invoke(cli, ["device", "list", *flags]) + assert result.exit_code == 1 + assert "cloud broke" in result.output - async def fake_adb_list(): - return [FakeAdbDevice()] - monkeypatch.setattr("mobilerun.cli.main.resolve_cloud_api_key", lambda: "token") - monkeypatch.setattr("mobilerun.cli.main.adb.list", fake_adb_list) - monkeypatch.setattr("mobilerun_sdk.AsyncMobilerun", FakeClient) +def test_local_listing_uses_local_scope(monkeypatch): + from mobilerun.cli import device_lifecycle - result = CliRunner().invoke(cli, ["devices"]) + class FakeCore: + def close(self): + pass - assert result.exit_code == 0, result.output - assert "emulator-5554" in result.output - assert "cloud broke" not in result.output + def list_devices(self, **kwargs): + assert kwargs["scope"] == "local" + return [ + SimpleNamespace( + device_id="serial", + capabilities={"backend": "local-android-adb"}, + name=None, + state=None, + device_type=None, + close=Mock(), + ) + ] + + monkeypatch.setattr(device_lifecycle, "core_client", lambda *_, **kw: FakeCore()) + result = CliRunner().invoke(cli, ["device", "list", "-j"]) + assert result.exit_code == 0 + assert '"device_id": "serial"' in result.stdout + + +@pytest.mark.parametrize( + "primary,legacy,expected", + [ + ("primary-token", "legacy-token", "primary-token"), + ("", "legacy-token", "legacy-token"), + (None, "legacy-token", "legacy-token"), + ("", "", "saved-token"), + (None, None, "saved-token"), + ], +) +def test_cloud_key_env_order_and_empty_fallback(monkeypatch, primary, legacy, expected): + from mobilerun.cli import deviceauth + + for name, value in ( + ("MOBILERUN_API_KEY", primary), + ("MOBILERUN_CLOUD_API_KEY", legacy), + ): + monkeypatch.delenv(name, raising=False) + if value is not None: + monkeypatch.setenv(name, value) + monkeypatch.setattr( + deviceauth, "load_credentials", lambda: {"api_key": "saved-token"} + ) + assert selection.resolve_cloud_api_key() == expected diff --git a/tests/test_configure_prompts.py b/tests/test_configure_prompts.py index 31609641..c43f7a57 100644 --- a/tests/test_configure_prompts.py +++ b/tests/test_configure_prompts.py @@ -114,7 +114,7 @@ def test_gemini_oauth_picker_saves_canonical_id_but_summarizes_display_name( monkeypatch.setattr( configure_wizard.ConfigLoader, "save", - lambda saved: saved_configs.append(saved), + lambda saved, path=None: saved_configs.append(saved), ) monkeypatch.setattr( configure_wizard, diff --git a/tests/test_core_local_runtime.py b/tests/test_core_local_runtime.py index d269b320..a1e8a5ff 100644 --- a/tests/test_core_local_runtime.py +++ b/tests/test_core_local_runtime.py @@ -1,7 +1,8 @@ import asyncio from types import SimpleNamespace -from mobilerun.cli import device_commands +import mobilerun.config_manager +from mobilerun.cli import selection from mobilerun.config_manager.config_manager import DeviceConfig @@ -34,7 +35,7 @@ async def connect(self): pass monkeypatch.setattr( - device_commands.ConfigLoader, + mobilerun.config_manager.ConfigLoader, "load", lambda _path: SimpleNamespace( device=SimpleNamespace( @@ -46,14 +47,19 @@ async def connect(self): ) ), ) - monkeypatch.setattr(device_commands, "AndroidDriver", FakeAndroidDriver) + from mobilerun_core_local.driver import android - driver, is_ios = asyncio.run( - device_commands._create_driver(None, None, None, False) - ) + monkeypatch.setattr(android, "AndroidDriver", FakeAndroidDriver) + + async def connect(): + selected = await selection.select_device() + return await selection._native_driver( + selected, selected.config, "https://example.test/v1" + ) + + driver = asyncio.run(connect()) assert isinstance(driver, FakeAndroidDriver) - assert is_ios is False assert android_calls == [ { "serial": "emulator-5556", @@ -77,7 +83,7 @@ async def fake_ensure_portal_ready(*args, **kwargs): setup_calls.append((args, kwargs)) monkeypatch.setattr( - device_commands.ConfigLoader, + mobilerun.config_manager.ConfigLoader, "load", lambda _path: SimpleNamespace( device=SimpleNamespace( @@ -89,11 +95,17 @@ async def fake_ensure_portal_ready(*args, **kwargs): ) ), ) - monkeypatch.setattr(device_commands, "AndroidDriver", FakeAndroidDriver) - monkeypatch.setattr( - device_commands, "ensure_portal_ready", fake_ensure_portal_ready - ) + from mobilerun_core_local.driver import android + + monkeypatch.setattr(android, "AndroidDriver", FakeAndroidDriver) + monkeypatch.setattr(android.portal, "ensure_portal_ready", fake_ensure_portal_ready) + + async def connect(): + selected = await selection.select_device() + return await selection._native_driver( + selected, selected.config, "https://example.test/v1" + ) - asyncio.run(device_commands._create_driver(None, None, None, False)) + asyncio.run(connect()) assert setup_calls == [] diff --git a/tests/test_delegation_config.py b/tests/test_delegation_config.py index da1a3548..847a97b6 100644 --- a/tests/test_delegation_config.py +++ b/tests/test_delegation_config.py @@ -7,6 +7,7 @@ from llama_index.core.llms.mock import MockLLM from selector_helpers import CompletedRun +import mobilerun.cli.run as cli_run from mobilerun import DelegationConfig, MobileAgent, MobileConfig from mobilerun.agent.droid import droid_agent from mobilerun.agent.selectors import ( @@ -15,7 +16,6 @@ LlamaIndexLLMSelector, ) from mobilerun.agent.utils import llm_loader, selector_loader -from mobilerun.cli import main as cli_main from mobilerun.config_manager import DecisionProfile, LLMProfile from mobilerun.config_manager.migrations import CURRENT_VERSION @@ -187,19 +187,21 @@ def test_cli_runs_new_config_with_existing_device_and_step_flags(monkeypatch, tm "load_llms_from_profiles", Mock(return_value={"app_opener": MockLLM()}), ) - monkeypatch.setattr(cli_main, "_setup_cli_logging", lambda _: None) - monkeypatch.setattr(cli_main, "print_telemetry_message", lambda **kw: None) - monkeypatch.setattr(cli_main, "_cleanup_android_keyboard", AsyncMock()) + monkeypatch.setattr(cli_run, "_setup_cli_logging", lambda _: None) + monkeypatch.setattr( + "mobilerun.telemetry.print_telemetry_message", lambda **kw: None + ) + monkeypatch.setattr(cli_run, "_cleanup_android_keyboard", AsyncMock()) agents = [] def initialize(**kwargs): agents.append(MobileAgent(**kwargs)) return SimpleNamespace(run=lambda: CompletedRun()) - monkeypatch.setattr(cli_main, "MobileAgent", initialize) + monkeypatch.setattr("mobilerun.MobileAgent", initialize) assert ( asyncio.run( - cli_main.run_command( + cli_run.run_command( "Check timer and write a note", config_path=str(config_path), device="emulator-5554", diff --git a/tests/test_driver_reexports.py b/tests/test_driver_reexports.py index 7b935460..f0ef6385 100644 --- a/tests/test_driver_reexports.py +++ b/tests/test_driver_reexports.py @@ -14,8 +14,8 @@ import mobilerun_core_local.driver.cloud import mobilerun_core_local.driver.ios import mobilerun_core_local.driver.recording -import mobilerun_core_local.driver.stealth import mobilerun_core_local.driver.visual_remote +from mobilerun_core_local.driver.stealth import StealthDriver as CoreStealthDriver import mobilerun.tools from mobilerun.tools.driver import ( @@ -66,7 +66,7 @@ def test_recording_driver_is_core_local_implementation(self): def test_stealth_driver_is_core_local_implementation(self): self.assertIs( StealthDriver, - mobilerun_core_local.driver.stealth.StealthDriver, + CoreStealthDriver, ) def test_visual_remote_driver_is_core_local_implementation(self): diff --git a/tests/test_gemini_oauth_llm.py b/tests/test_gemini_oauth_llm.py index cc23c086..a532bbff 100644 --- a/tests/test_gemini_oauth_llm.py +++ b/tests/test_gemini_oauth_llm.py @@ -422,7 +422,9 @@ def unexpected_load(*args): monkeypatch.setattr( GeminiOAuthCodeAssistLLM, "_load_credentials_from_file", unexpected_load ) - with pytest.raises(ValueError, match="retired.*gemini-3.8-flash-tiered.*configure"): + with pytest.raises( + ValueError, match="retired.*gemini-3.8-flash-tiered.*auth login gemini" + ): GeminiOAuthCodeAssistLLM(**{argument: model}) diff --git a/tests/test_grok_cli.py b/tests/test_grok_cli.py index 5f5f536f..3d338bff 100644 --- a/tests/test_grok_cli.py +++ b/tests/test_grok_cli.py @@ -6,6 +6,7 @@ from click.testing import CliRunner from rich.console import Console +import mobilerun.cli.auth as cli_auth import mobilerun.cli.configure_wizard as configure_wizard import mobilerun.cli.main as cli_main from mobilerun.agent.providers.registry import resolve_provider_variant @@ -69,16 +70,18 @@ def test_configure_xai_command_forwards_device_code_options( ) -> None: calls: list[dict] = [] monkeypatch.setattr( - cli_main, + cli_auth, "_run_grok_oauth_login", lambda **kwargs: calls.append(kwargs), ) credential_path = tmp_path / "auth-profiles.json" + monkeypatch.setattr(cli_auth, "input_allowed", lambda _: True) result = CliRunner().invoke( cli_main.cli, [ - "configure", + "auth", + "login", "xai", "--credential-path", str(credential_path), @@ -108,16 +111,18 @@ def test_configure_anthropic_forwards_timeout_and_browser_preference( ) -> None: calls: list[dict] = [] monkeypatch.setattr( - cli_main, + cli_auth, "_run_anthropic_oauth_login", lambda **kwargs: calls.append(kwargs), ) credential_path = tmp_path / "auth-profiles.json" + monkeypatch.setattr(cli_auth, "input_allowed", lambda _: True) result = CliRunner().invoke( cli_main.cli, [ - "configure", + "auth", + "login", "anthropic", "--credential-path", str(credential_path), @@ -140,7 +145,7 @@ def test_configure_anthropic_forwards_timeout_and_browser_preference( def test_configure_oauth_timeout_must_be_positive_and_finite() -> None: result = CliRunner().invoke( cli_main.cli, - ["configure", "xai", "--timeout", "nan"], + ["auth", "login", "xai", "--timeout", "nan"], ) assert result.exit_code == 2 @@ -208,11 +213,11 @@ def test_configure_help_advertises_only_xai_provider_and_login_command() -> None run_help = runner.invoke(cli_main.cli, ["run", "--help"]) configure_help = runner.invoke(cli_main.cli, ["configure", "--help"]) - xai_help = runner.invoke(cli_main.cli, ["configure", "xai", "--help"]) + xai_help = runner.invoke(cli_main.cli, ["auth", "login", "xai", "--help"]) removed_grok_command = runner.invoke(cli_main.cli, ["configure", "grok"]) assert run_help.exit_code == 0 - assert "XAI" in run_help.output + assert "xai" in run_help.output assert "xai_oauth" not in run_help.output assert "grok_oauth" not in run_help.output assert configure_help.exit_code == 0 @@ -241,7 +246,7 @@ def test_exact_xai_configure_forms_keep_provider_and_auth_fixed( monkeypatch.setattr( configure_wizard.ConfigLoader, "save", - lambda saved: saved_configs.append(saved), + lambda saved, path=None: saved_configs.append(saved), ) def choose_model( # type: ignore[no-untyped-def] @@ -274,11 +279,15 @@ def choose_model( # type: ignore[no-untyped-def] ), ) monkeypatch.setattr( - cli_main, + cli_auth, "_run_grok_oauth_login", lambda **kwargs: login_calls.append(kwargs), ) + import importlib + + configure = importlib.import_module("mobilerun.cli.configure") + monkeypatch.setattr(configure, "input_allowed", lambda _: True) result = CliRunner().invoke( cli_main.cli, ["configure", "--provider", "XAI", "--auth-mode", auth_mode], @@ -306,7 +315,7 @@ def test_provider_only_flag_does_not_auto_enter_model_flow(monkeypatch) -> None: monkeypatch.setattr( configure_wizard.ConfigLoader, "save", - lambda saved: saved_configs.append(saved), + lambda saved, path=None: saved_configs.append(saved), ) monkeypatch.setattr( configure_wizard, @@ -352,7 +361,7 @@ def test_fixed_provider_and_auth_model_back_returns_to_top_level_once( monkeypatch.setattr( configure_wizard.ConfigLoader, "save", - lambda saved: saved_configs.append(saved), + lambda saved, path=None: saved_configs.append(saved), ) def choose_model( # type: ignore[no-untyped-def] diff --git a/tests/test_ios_local_portal_wiring.py b/tests/test_ios_local_portal_wiring.py index d1abc7d5..e258a434 100644 --- a/tests/test_ios_local_portal_wiring.py +++ b/tests/test_ios_local_portal_wiring.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from types import SimpleNamespace def run(coro): @@ -10,22 +11,28 @@ def run(coro): def test_auth_token_defaults_to_none(monkeypatch): from mobilerun.config_manager.config_manager import DeviceConfig - monkeypatch.delenv("MOBILERUN_DEVICE_TOKEN", raising=False) - assert DeviceConfig().resolve_auth_token() is None + monkeypatch.delenv("MOBILERUN_IOS_PORTAL_TOKEN", raising=False) + assert DeviceConfig(platform="ios").resolve_auth_token() is None def test_auth_token_reads_config_value(monkeypatch): from mobilerun.config_manager.config_manager import DeviceConfig - monkeypatch.delenv("MOBILERUN_DEVICE_TOKEN", raising=False) - assert DeviceConfig(auth_token="from-config").resolve_auth_token() == "from-config" + monkeypatch.delenv("MOBILERUN_IOS_PORTAL_TOKEN", raising=False) + assert ( + DeviceConfig(platform="ios", auth_token="from-config").resolve_auth_token() + == "from-config" + ) def test_auth_token_env_overrides_config(monkeypatch): from mobilerun.config_manager.config_manager import DeviceConfig - monkeypatch.setenv("MOBILERUN_DEVICE_TOKEN", "from-env") - assert DeviceConfig(auth_token="from-config").resolve_auth_token() == "from-env" + monkeypatch.setenv("MOBILERUN_IOS_PORTAL_TOKEN", "from-env") + assert ( + DeviceConfig(platform="ios", auth_token="from-config").resolve_auth_token() + == "from-env" + ) LOCAL_PORTAL_STATE = { @@ -108,112 +115,84 @@ def test_android_state_provider_vision_contract_uses_point_space(): assert (state.coordinate_scale_x, state.coordinate_scale_y) == (1.0, 1.0) -def test_device_commands_ui_pairs_portal_http_driver_with_android_provider(monkeypatch): - """`mobilerun device ui` must not pair IOSPortalHttpDriver with - IOSStateProvider: the latter regex-parses a legacy string tree and would - silently yield zero elements for the Android-shaped dict the --local - portal actually returns.""" +def test_device_commands_ui_returns_core_state(monkeypatch): + import json + from click.testing import CliRunner - from mobilerun_core_local.driver.ios import IOSPortalHttpDriver + from mobilerun_core import Mobilerun - from mobilerun.cli import device_commands + from mobilerun.cli.main import cli - class FakePortalHttpDriver(IOSPortalHttpDriver): - def __init__(self): # skip the httpx-client base __init__ + class Device: + def close(self): pass - async def get_ui_tree(self): - return LOCAL_PORTAL_STATE - - driver = FakePortalHttpDriver() - - async def fake_create_driver(*args, **kwargs): - return driver, True - - monkeypatch.setattr(device_commands, "_create_driver", fake_create_driver) - - result = CliRunner().invoke(device_commands.device_cli, ["ui"]) + def ui_json(self): + return json.dumps(LOCAL_PORTAL_STATE) + monkeypatch.setattr(Mobilerun, "connect", lambda *a, **kw: Device()) + result = CliRunner().invoke( + cli, ["device", "ui", "-d", "http://127.0.0.1:8080", "-P", "ios", "-j"] + ) assert result.exit_code == 0, result.output - assert "Privacy & Security" in result.output + assert json.loads(result.stdout) == LOCAL_PORTAL_STATE + +def test_device_commands_route_ios_through_facade(tmp_path, monkeypatch): + from mobilerun_core import Mobilerun -def test_device_commands_route_ios_through_factory(tmp_path, monkeypatch): from mobilerun.cli import device_commands created = {} - class FakeDriver: - platform = "iOS" - - async def connect(self): - created["connected"] = True - - async def fake_create_ios_driver(url, *, token=None, transport=None): - created["url"] = url - created["token"] = token - return FakeDriver() - - monkeypatch.setattr(device_commands, "create_ios_driver", fake_create_ios_driver) - monkeypatch.setenv("MOBILERUN_DEVICE_TOKEN", "sekret") + def connect(self, device_id, **kwargs): + created.update(kwargs) + return SimpleNamespace(close=lambda: None) + monkeypatch.setattr(Mobilerun, "connect", connect) + monkeypatch.setenv("MOBILERUN_IOS_PORTAL_TOKEN", "sekret") cfg = tmp_path / "config.yaml" cfg.write_text("_version: 7\ndevice:\n platform: ios\n") - - driver, is_ios = run( - device_commands._create_driver( - device="http://127.0.0.1:8080", - config_path=str(cfg), - tcp=None, - ios=True, - ) - ) - - assert is_ios is True - assert created == { - "url": "http://127.0.0.1:8080", - "token": "sekret", - "connected": True, - } + with device_commands.device_driver( + { + "device": "http://127.0.0.1:8080", + "config_path": str(cfg), + "platform": "ios", + "api_url": "https://example.test/v1", + } + ): + pass + assert created["url"] == "http://127.0.0.1:8080" + assert created["token"] == "sekret" + assert created["backend"] == "local-ios-http" def test_device_commands_discover_when_no_device(tmp_path, monkeypatch): + from mobilerun_core import Mobilerun + from mobilerun.cli import device_commands created = {} - class FakeDriver: - platform = "iOS" - - async def connect(self): - pass - - async def fake_discover_ios_device(): + async def discover(): return "http://127.0.0.1:8081" - async def fake_create_ios_driver(url, *, token=None, transport=None): - created["url"] = url - return FakeDriver() - - monkeypatch.setattr( - device_commands, "discover_ios_device", fake_discover_ios_device - ) - monkeypatch.setattr(device_commands, "create_ios_driver", fake_create_ios_driver) - monkeypatch.delenv("MOBILERUN_DEVICE_TOKEN", raising=False) + def connect(self, device_id, **kwargs): + created.update(kwargs) + return SimpleNamespace(close=lambda: None) + monkeypatch.setattr("mobilerun_core_local.driver.ios.discover_ios_device", discover) + monkeypatch.setattr(Mobilerun, "connect", connect) cfg = tmp_path / "config.yaml" cfg.write_text("_version: 7\ndevice:\n platform: ios\n") - - driver, is_ios = run( - device_commands._create_driver( - device=None, - config_path=str(cfg), - tcp=None, - ios=True, - ) - ) - - assert is_ios is True + with device_commands.device_driver( + { + "config_path": str(cfg), + "platform": "ios", + "api_url": "https://example.test/v1", + } + ): + pass assert created["url"] == "http://127.0.0.1:8081" diff --git a/tests/test_keyboard_cleanup.py b/tests/test_keyboard_cleanup.py index 0c244b66..66bf4849 100644 --- a/tests/test_keyboard_cleanup.py +++ b/tests/test_keyboard_cleanup.py @@ -7,7 +7,7 @@ portal_ime_id, ) -from mobilerun.cli import main as cli_main +import mobilerun.cli.run as cli_main from mobilerun.config_manager.config_manager import ( DeviceConfig, MobileConfig, @@ -44,11 +44,11 @@ def _serials(devices): def _run_cli(monkeypatch, config, agent_factory, **kwargs): - monkeypatch.setattr(cli_main.ConfigLoader, "load", lambda _: config) + monkeypatch.setattr("mobilerun.config_manager.ConfigLoader.load", lambda _: config) monkeypatch.setattr(cli_main, "_setup_cli_logging", lambda _: None) - monkeypatch.setattr(cli_main, "print_telemetry_message", lambda **_: None) + monkeypatch.setattr("mobilerun.telemetry.print_telemetry_message", lambda **_: None) monkeypatch.setattr(cli_main.console, "print", lambda *a, **kw: None) - monkeypatch.setattr(cli_main, "MobileAgent", agent_factory) + monkeypatch.setattr("mobilerun.MobileAgent", agent_factory) return asyncio.run(cli_main.run_command("Open settings", debug=False, **kwargs)) @@ -59,7 +59,7 @@ async def stream_events(self): def __await__(self): async def done(): - return SimpleNamespace(success=True) + return SimpleNamespace(success=True, reason="done", steps=1) return done().__await__() diff --git a/tests/test_oauth_manual_env_var.py b/tests/test_oauth_manual_env_var.py index d75b15cf..15f0eacc 100644 --- a/tests/test_oauth_manual_env_var.py +++ b/tests/test_oauth_manual_env_var.py @@ -58,16 +58,16 @@ def login(request, monkeypatch, tmp_path): ("yes", None, True), ("TRUE", None, True), ("Yes", None, True), - (None, "1", True), - (None, "true", True), - (None, "YES", True), + (None, "1", False), + (None, "true", False), + (None, "YES", False), ("true", "false", True), ("false", "true", False), ("0", "true", False), ("no", "true", False), ("off", "true", False), ("invalid", "true", False), - ("", "true", True), + ("", "true", False), ("", "false", False), ("", None, False), ("0", None, False), diff --git a/tests/test_phoenix_tracing.py b/tests/test_phoenix_tracing.py index 783501a6..4cae1dd8 100644 --- a/tests/test_phoenix_tracing.py +++ b/tests/test_phoenix_tracing.py @@ -45,7 +45,7 @@ def instrument(self, **kwargs): monkeypatch.setattr( llama_index_instrumentation, "LlamaIndexInstrumentor", FakeInstrumentor ) - monkeypatch.setenv("phoenix_project_name", "compatibility-tests") + monkeypatch.setenv("PHOENIX_PROJECT_NAME", "compatibility-tests") supplied_provider = object() result = phoenix.arize_phoenix_callback_handler( diff --git a/tests/test_sdk_debug_logging.py b/tests/test_sdk_debug_logging.py index 6d841f35..37901bac 100644 --- a/tests/test_sdk_debug_logging.py +++ b/tests/test_sdk_debug_logging.py @@ -170,7 +170,7 @@ def test_setup_command_updates_in_place_only_when_asked( ): import asyncio - from mobilerun.cli import main + import mobilerun.cli.setup as main calls = [] diff --git a/tests/test_setup_portal_explicit_apk.py b/tests/test_setup_portal_explicit_apk.py index 57d474a7..8a330640 100644 --- a/tests/test_setup_portal_explicit_apk.py +++ b/tests/test_setup_portal_explicit_apk.py @@ -5,7 +5,7 @@ import pytest -from mobilerun.cli import main +import mobilerun.cli.setup as main A11Y_SERVICE = ( "com.mobilerun.portal/com.mobilerun.portal.service.MobilerunAccessibilityService" @@ -138,7 +138,7 @@ def test_dropped_service_is_enabled_again(run_setup, apk, capsys): assert device.enable_writes == 2 assert device.service_enabled - out = capsys.readouterr().out + out = capsys.readouterr().err assert "Accessibility service enabled successfully!" in out assert "Setup complete!" in out assert "ACCESSIBILITY_SETTINGS" not in " ".join(device.shell_calls) @@ -151,7 +151,7 @@ def test_ready_service_is_not_enabled_again(run_setup, apk, capsys): assert device.enable_writes == 1 assert len(device.provider_queries()) == 1 - assert "Setup complete!" in capsys.readouterr().out + assert "Setup complete!" in capsys.readouterr().err def test_starting_service_is_not_enabled_again(run_setup, apk, capsys): @@ -161,7 +161,7 @@ def test_starting_service_is_not_enabled_again(run_setup, apk, capsys): assert device.enable_writes == 1 assert len(device.provider_queries()) == 3 - assert "Setup complete!" in capsys.readouterr().out + assert "Setup complete!" in capsys.readouterr().err def test_service_without_active_window_is_ready(run_setup, apk, capsys): @@ -169,7 +169,7 @@ def test_service_without_active_window_is_ready(run_setup, apk, capsys): run_setup(device, path=apk) - out = capsys.readouterr().out + out = capsys.readouterr().err assert "Setup complete!" in out assert "ACCESSIBILITY_SETTINGS" not in " ".join(device.shell_calls) @@ -179,7 +179,7 @@ def test_unresponsive_service_falls_back_to_manual_setup(run_setup, apk, capsys) run_setup(device, path=apk) - out = capsys.readouterr().out + out = capsys.readouterr().err assert device.provider_queries() assert "Setup complete!" not in out assert "Accessibility service enabled successfully!" not in out diff --git a/tests/test_state_fetch_retry_policy.py b/tests/test_state_fetch_retry_policy.py index adcf976d..cde8fd35 100644 --- a/tests/test_state_fetch_retry_policy.py +++ b/tests/test_state_fetch_retry_policy.py @@ -214,22 +214,24 @@ async def run(): assert cancelled == [True] -def test_device_ui_shows_info_progress_on_stderr(capsys): - from mobilerun.cli import device_commands +def test_device_ui_shows_info_progress_on_stderr(monkeypatch): + from click.testing import CliRunner + from mobilerun_core import Mobilerun - logger = logging.getLogger("mobilerun") - saved = (logger.handlers[:], logger.level, logger.propagate) - logger.handlers = [] - try: - device_commands._log_progress_to_stderr() - logger.info("Waiting for the UI state from the device...") - out, err = capsys.readouterr() - assert out == "" - assert err == "Waiting for the UI state from the device...\n" - - # An already-configured logger (SDK / agent CLI) is left alone. - handlers = logger.handlers[:] - device_commands._log_progress_to_stderr() - assert logger.handlers == handlers - finally: - logger.handlers, logger.level, logger.propagate = saved + from mobilerun.cli.main import cli + + class Device: + def close(self): + pass + + def ui_json(self): + logging.getLogger("mobilerun").info( + "Waiting for the UI state from the device..." + ) + return '{"a11y_tree": {}}' + + monkeypatch.setattr(Mobilerun, "connect", lambda *a, **kw: Device()) + result = CliRunner().invoke(cli, ["device", "ui", "-d", "serial", "-j"]) + assert result.exit_code == 0, result.output + assert result.stdout == '{"a11y_tree": {}}\n' + assert result.stderr == "Waiting for the UI state from the device...\n" diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index b89b6d16..10f74429 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -58,11 +58,11 @@ def test_config_false_wins_when_environment_is_true(monkeypatch) -> None: ("false", "true", False), ("true", "false", True), ("", "true", False), - (None, "false", False), + (None, "false", True), (None, "yes", True), ], ) -def test_primary_environment_variable_takes_precedence_over_legacy( +def test_primary_environment_variable_is_used_and_legacy_ignored( monkeypatch, primary: str | None, legacy: str, expected: bool ) -> None: if primary is None: @@ -341,8 +341,8 @@ def test_print_telemetry_message_respects_config_false(monkeypatch) -> None: assert messages == [tracker.TELEMETRY_DISABLED_MESSAGE] -def test_cli_run_and_test_forward_loaded_telemetry_config(monkeypatch) -> None: - from mobilerun.cli import main as cli_main +def test_cli_run_forwards_loaded_telemetry_config(monkeypatch) -> None: + import mobilerun.cli.run as cli_main from mobilerun.config_manager.config_manager import MobileConfig, TelemetryConfig class StopAfterTelemetryMessage(Exception): @@ -358,12 +358,11 @@ def capture_config(*, config_enabled=True) -> None: async def skip_keyboard_cleanup(config, serial=None) -> None: return None - monkeypatch.setattr(cli_main.ConfigLoader, "load", lambda _: config) + monkeypatch.setattr("mobilerun.config_manager.ConfigLoader.load", lambda _: config) monkeypatch.setattr(cli_main, "_setup_cli_logging", lambda _: None) - monkeypatch.setattr(cli_main, "print_telemetry_message", capture_config) + monkeypatch.setattr("mobilerun.telemetry.print_telemetry_message", capture_config) monkeypatch.setattr(cli_main, "_cleanup_android_keyboard", skip_keyboard_cleanup) monkeypatch.setattr(cli_main.console, "print", lambda *args, **kwargs: None) assert asyncio.run(cli_main.run_command("Check telemetry", debug=False)) is False - assert asyncio.run(cli_main.test("Check telemetry", debug=False)) is None - assert forwarded == [False, False] + assert forwarded == [False] diff --git a/tests/test_typesafe_cli.py b/tests/test_typesafe_cli.py index a37ec485..d22e694a 100644 --- a/tests/test_typesafe_cli.py +++ b/tests/test_typesafe_cli.py @@ -1,4 +1,5 @@ import asyncio +import json from dataclasses import replace from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -6,6 +7,7 @@ import pytest from click.testing import CliRunner +import mobilerun.cli.run as cli_run from mobilerun.cli import configure_wizard from mobilerun.cli import main as cli_main from mobilerun.config_manager.config_manager import ( @@ -23,26 +25,30 @@ async def stream_events(self): def __await__(self): async def done(): - return SimpleNamespace(success=True) + return SimpleNamespace(success=True, reason=None, steps=0) return done().__await__() def _run(monkeypatch, config, **kwargs): - monkeypatch.setattr(cli_main.ConfigLoader, "load", lambda _path=None: config) - monkeypatch.setattr(cli_main, "_setup_cli_logging", lambda _: None) - monkeypatch.setattr(cli_main, "print_telemetry_message", lambda **kw: None) - monkeypatch.setattr(cli_main, "_cleanup_android_keyboard", AsyncMock()) + monkeypatch.setattr( + "mobilerun.config_manager.ConfigLoader.load", lambda _path=None: config + ) + monkeypatch.setattr(cli_run, "_setup_cli_logging", lambda _: None) + monkeypatch.setattr( + "mobilerun.telemetry.print_telemetry_message", lambda **kw: None + ) + monkeypatch.setattr(cli_run, "_cleanup_android_keyboard", AsyncMock()) load_llm = Mock() - monkeypatch.setattr(cli_main, "load_llm", load_llm) + monkeypatch.setattr("mobilerun.agent.utils.llm_picker.load_llm", load_llm) agents = [] def agent(**agent_kwargs): agents.append(agent_kwargs) return SimpleNamespace(run=lambda: _Done()) - monkeypatch.setattr(cli_main, "MobileAgent", agent) - success = asyncio.run(cli_main.run_command("Open Clock", **kwargs)) + monkeypatch.setattr("mobilerun.MobileAgent", agent) + success = asyncio.run(cli_run.run_command("Open Clock", **kwargs)) return success, agents, load_llm @@ -167,21 +173,24 @@ def test_delegate_model_alone_keeps_the_agent_provider(monkeypatch): ) assert agents[0]["config"].llm_profiles["delegate"] == LLMProfile( - provider="OpenAILike", model="org/model-b", base_url="https://llm.test" + provider="OpenAILike", + model="org/model-b", + base_url="https://llm.test", + kwargs={"max_retries": 1}, ) def test_delegate_model_on_the_agent_provider_keeps_its_settings(monkeypatch): config = MobileConfig() config.llm_profiles["fast_agent"] = LLMProfile( - provider="OpenAI", model="gpt-6.1-sol", kwargs={"timeout": 30} + provider="OpenAI", model="gpt-6.1-sol", kwargs={"timeout": 30, "max_retries": 4} ) _, agents, _ = _run( monkeypatch, config, provider="TypeSafe", delegate_model="openai/gpt-6-luna" ) assert agents[0]["config"].llm_profiles["delegate"] == LLMProfile( - provider="OpenAI", model="gpt-6-luna", kwargs={"timeout": 30} + provider="OpenAI", model="gpt-6-luna", kwargs={"timeout": 30, "max_retries": 4} ) @@ -200,8 +209,8 @@ def test_delegate_model_replaces_the_configured_delegate_llm(monkeypatch): def test_run_cli_forwards_the_delegate_options(monkeypatch): - run_command = AsyncMock(return_value=True) - monkeypatch.setattr(cli_main, "run_command", run_command) + run_command = AsyncMock(return_value=(True, None, 0)) + monkeypatch.setattr(cli_run, "_run_task", run_command) result = CliRunner().invoke( cli_main.cli, @@ -350,12 +359,12 @@ def test_typesafe_flag_keeps_the_configured_profile(monkeypatch): ) -def test_typesafe_base_url_goes_to_jev_not_the_helper_llms(monkeypatch): +def test_typesafe_llm_url_goes_to_jev_not_the_helper_llms(monkeypatch): _, agents, _ = _run( monkeypatch, MobileConfig(), provider="TypeSafe", - base_url="https://typesafe.test", + llm_url="https://typesafe.test", ) assert "base_url" not in agents[0] @@ -786,3 +795,79 @@ def test_changing_the_llm_keeps_a_delegate_with_its_own_endpoint(monkeypatch): ) assert config.llm_profiles["delegate"] == delegate + + +def test_cli_delegate_model_accepts_openai_oauth_spelling(monkeypatch): + config = MobileConfig() + monkeypatch.setattr("mobilerun.config_manager.ConfigLoader.load", lambda _: config) + monkeypatch.setattr(cli_run, "_setup_cli_logging", lambda _: None) + monkeypatch.setattr( + "mobilerun.telemetry.print_telemetry_message", lambda **kw: None + ) + monkeypatch.setattr(cli_run, "_cleanup_android_keyboard", AsyncMock()) + agents = [] + + def agent(**kwargs): + agents.append(kwargs) + return SimpleNamespace(run=lambda: _Done()) + + monkeypatch.setattr("mobilerun.MobileAgent", agent) + # Polish keeps the constructor import at module scope; devx imports it lazily. + if hasattr(cli_run, "MobileAgent"): + monkeypatch.setattr(cli_run, "MobileAgent", agent) + result = CliRunner().invoke( + cli_main.cli, + [ + "run", + "Open Clock", + "-p", + "TypeSafe", + "--delegate-model", + "openai-oauth/gpt-x", + ], + ) + + assert result.exit_code == 0, result.output + profile = agents[0]["config"].llm_profiles["delegate"] + assert (profile.provider, profile.model) == ("openai_oauth", "gpt-x") + + +def test_bad_delegation_spec_is_a_cli_usage_error(monkeypatch): + monkeypatch.setattr( + "mobilerun.config_manager.ConfigLoader.load", lambda _: MobileConfig() + ) + result = CliRunner().invoke( + cli_main.cli, ["run", "x", "--delegate-model", "openai/gpt-x", "-j"] + ) + + assert result.exit_code == 2, result.output + assert "--delegate-model needs a decision model" in result.stderr + assert result.stdout == "" + assert "Traceback" not in result.output + + +def test_delegate_profile_config_error_is_a_json_failure(monkeypatch): + monkeypatch.setattr( + "mobilerun.config_manager.ConfigLoader.load", lambda _: MobileConfig() + ) + result = CliRunner().invoke( + cli_main.cli, + ["run", "x", "-p", "TypeSafe", "--delegate-model", "openai-like/x", "-j"], + ) + + assert result.exit_code == 1, result.output + assert "needs a base URL" in json.loads(result.stdout)["error"] + assert "Traceback" not in result.output + + +def test_conflicting_delegate_options_fail_before_config_load(monkeypatch): + load = Mock(side_effect=AssertionError("configuration must not be loaded")) + monkeypatch.setattr("mobilerun.config_manager.ConfigLoader.load", load) + result = CliRunner().invoke( + cli_main.cli, + ["run", "x", "-p", "TypeSafe", "--no-delegate", "--delegate-model", "a/b"], + ) + + assert result.exit_code == 2, result.output + assert "--delegate-model cannot be used with --no-delegate" in result.stderr + load.assert_not_called() diff --git a/tests/test_vision_only_run_cli.py b/tests/test_vision_only_run_cli.py index 7bde818f..694af8b1 100644 --- a/tests/test_vision_only_run_cli.py +++ b/tests/test_vision_only_run_cli.py @@ -11,31 +11,32 @@ class RunCliVisionOnlyTest(unittest.TestCase): def test_vision_only_forwards_normal_run_options(self): - async_run_command = AsyncMock(return_value=True) + async_run_command = AsyncMock(return_value=(True, "done", 1)) runner = CliRunner() - with patch("mobilerun.cli.main.run_command", async_run_command): + with patch("mobilerun.cli.run._run_task", async_run_command): result = runner.invoke( cli, [ "run", "Check Wi-Fi", "--vision-only", - "--ios", - "--control-backend", + "--platform", + "ios", + "--backend", "visual-remote", "--device", "http://localhost:8090", - "--device-id", + "--backend-device", "phone-1", "--provider", - "OpenAIResponses", + "openai", "--model", "gpt-5.1", - "--steps", + "--max-steps", "8", "--no-stream", - "--debug", + "--verbose", ], ) @@ -44,7 +45,7 @@ def test_vision_only_forwards_normal_run_options(self): kwargs = async_run_command.await_args.kwargs self.assertEqual(kwargs["command"], "Check Wi-Fi") self.assertTrue(kwargs["vision_only"]) - self.assertTrue(kwargs["ios"]) + self.assertTrue(kwargs["platform"] == "ios") self.assertEqual(kwargs["control_backend"], "visual-remote") self.assertEqual(kwargs["device"], "http://localhost:8090") self.assertEqual(kwargs["device_id"], "phone-1") @@ -55,12 +56,12 @@ def test_vision_only_forwards_normal_run_options(self): self.assertTrue(kwargs["debug"]) def test_omitted_control_backend_preserves_default_backend_behavior(self): - async_run_command = AsyncMock(return_value=True) + async_run_command = AsyncMock(return_value=(True, "done", 1)) runner = CliRunner() with ( - patch("mobilerun.cli.main.run_command", async_run_command), - patch("mobilerun.cli.main.adb.device", AsyncMock(return_value=None)), + patch("mobilerun.cli.run._run_task", async_run_command), + patch("mobilerun.cli.run.adb.device", AsyncMock(return_value=None)), ): result = runner.invoke(cli, ["run", "Check Wi-Fi", "--vision-only"]) @@ -75,7 +76,7 @@ def test_run_help_documents_public_flags_only(self): self.assertEqual(result.exit_code, 0, result.output) self.assertIn("--vision-only", result.output) - self.assertIn("--control-backend", result.output) + self.assertIn("--backend", result.output) self.assertIn("visual-remote", result.output) self.assertNotIn("--" + "connection", result.output) @@ -89,7 +90,7 @@ async def stream_events(self): def __await__(self): async def done(): - return SimpleNamespace(success=True) + return SimpleNamespace(success=True, reason="done", steps=1) return done().__await__() @@ -102,17 +103,18 @@ def run(self): with ( patch( - "mobilerun.cli.main.ConfigLoader.load", + "mobilerun.config_manager.ConfigLoader.load", return_value=MobileConfig(), ), - patch("mobilerun.cli.main.MobileAgent", FakeAgent), - patch("mobilerun.cli.main.discover_ios_device", AsyncMock()) as discover, - patch("mobilerun.cli.main.validate_ios_portal_url") as validate, + patch("mobilerun.MobileAgent", FakeAgent), + patch( + "mobilerun_core_local.driver.ios.discover_ios_device", AsyncMock() + ) as discover, ): success = asyncio.run( run_command( "Check Wi-Fi", - ios=True, + platform="ios", vision_only=True, control_backend="visual-remote", device="http://localhost:8090", @@ -123,7 +125,6 @@ def run(self): self.assertTrue(success) discover.assert_not_called() - validate.assert_not_called() config = created_agents[0]["config"] self.assertEqual(config.device.platform, "ios") self.assertEqual(config.device.control_backend, "visual-remote") @@ -144,7 +145,7 @@ async def stream_events(self): def __await__(self): async def done(): - return SimpleNamespace(success=True) + return SimpleNamespace(success=True, reason="done", steps=1) return done().__await__() @@ -157,10 +158,10 @@ def run(self): with ( patch( - "mobilerun.cli.main.ConfigLoader.load", + "mobilerun.config_manager.ConfigLoader.load", return_value=MobileConfig(), ), - patch("mobilerun.cli.main.MobileAgent", FakeAgent), + patch("mobilerun.MobileAgent", FakeAgent), ): success = asyncio.run( run_command( @@ -190,7 +191,7 @@ async def stream_events(self): def __await__(self): async def done(): - return SimpleNamespace(success=True) + return SimpleNamespace(success=True, reason="done", steps=1) return done().__await__() @@ -212,9 +213,9 @@ def run(self): ) with ( - patch("mobilerun.cli.main.ConfigLoader.load", return_value=config), - patch("mobilerun.cli.main.MobileAgent", FakeAgent), - patch("mobilerun.cli.main.adb.device", AsyncMock()) as adb_device, + patch("mobilerun.config_manager.ConfigLoader.load", return_value=config), + patch("mobilerun.MobileAgent", FakeAgent), + patch("mobilerun.cli.run.adb.device", AsyncMock()) as adb_device, ): success = asyncio.run(run_command("Check Wi-Fi", debug=False)) diff --git a/uv.lock b/uv.lock index 140a017d..3600a679 100644 --- a/uv.lock +++ b/uv.lock @@ -1438,7 +1438,7 @@ wheels = [ [[package]] name = "mobilerun" -version = "0.7.4" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, @@ -1459,6 +1459,7 @@ dependencies = [ { name = "llama-index-llms-openrouter" }, { name = "llama-index-workflows" }, { name = "mcp" }, + { name = "mobilerun-core", extra = ["local"] }, { name = "mobilerun-core-local", extra = ["cloud"] }, { name = "mobilerun-sdk" }, { name = "opentelemetry-exporter-otlp-proto-http" }, @@ -1468,6 +1469,7 @@ dependencies = [ { name = "pydantic" }, { name = "pyjwt", extra = ["crypto"] }, { name = "rich" }, + { name = "segno" }, { name = "urllib3" }, ] @@ -1534,8 +1536,9 @@ requires-dist = [ { name = "llama-index-workflows", specifier = ">=2.16.0,<3.0.0" }, { name = "mcp", specifier = ">=1.28.1,<2.0.0" }, { name = "mobilerun", extras = ["langfuse"], marker = "extra == 'dev'" }, - { name = "mobilerun-core-local", extras = ["cloud"], specifier = ">=0.6.3" }, - { name = "mobilerun-sdk", specifier = ">=3.2.0" }, + { name = "mobilerun-core", extras = ["local"], specifier = ">=1.12.0" }, + { name = "mobilerun-core-local", extras = ["cloud"], specifier = ">=0.7.2" }, + { name = "mobilerun-sdk", specifier = ">=6.1.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "openinference-instrumentation-llama-index", marker = "extra == 'langfuse'", specifier = ">=3.0.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.40.0" }, @@ -1548,6 +1551,7 @@ requires-dist = [ { name = "rich", specifier = ">=14.1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.13.0" }, { name = "safety", marker = "extra == 'dev'", specifier = ">=3.2.11" }, + { name = "segno", specifier = ">=1.6.6" }, { name = "typesafe-sdk", marker = "extra == 'typesafe'", specifier = ">=0.7.5,<0.8.0" }, { name = "urllib3", specifier = ">=2.8.0" }, ] @@ -1564,9 +1568,26 @@ dev = [ { name = "safety", specifier = ">=3.2.11" }, ] +[[package]] +name = "mobilerun-core" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mobilerun-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/f4/356f9ad9af849c1152fe466967cffd8f5973ffef62aa40f7e607d4646603/mobilerun_core-1.12.0.tar.gz", hash = "sha256:cca13d678ffb715dfcbd76d1166bdd5f44799bcb6920eb7129531e43dd5a13d3", size = 123751, upload-time = "2026-11-12T19:05:12.957Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/76/593c82c4c0dfdf22e75dfc1b8d248c591033aeba7ba28c744cc2349917ac/mobilerun_core-1.12.0-py3-none-any.whl", hash = "sha256:2f754c26712ee5fc42e30c1ab08154134b497d19579a2bba3c63dda081305190", size = 63833, upload-time = "2026-11-12T19:05:11.268Z" }, +] + +[package.optional-dependencies] +local = [ + { name = "mobilerun-core-local" }, +] + [[package]] name = "mobilerun-core-local" -version = "0.6.3" +version = "0.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-adbutils" }, @@ -1574,9 +1595,9 @@ dependencies = [ { name = "requests" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/db/ce/288ebd92a732d314d5122186863c72ed72b33cab70cce3073d5aaf8130e3/mobilerun_core_local-0.6.3.tar.gz", hash = "sha256:f7187d42f060767e58037c3aa3bdbf774920ea4e4519879c301c9b2bc7a00458", size = 54914, upload-time = "2026-10-13T17:02:26.808Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/b1/4d0c69257cc19ec85c2c816bdce4fc9d25f5af2def1de59cc89074988326/mobilerun_core_local-0.7.2.tar.gz", hash = "sha256:4730e1b839cd0d2d268f1ff3e830da0dde0152b74e782ba52ca96da830fb704b", size = 64928, upload-time = "2026-11-03T15:58:07.926Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/19572366a08eef8c8c475693d71432af16ed5e12efcdc610cb2cad51356d/mobilerun_core_local-0.6.3-py3-none-any.whl", hash = "sha256:91364da308591a3c50911f481e4a6d783cde9ae91cc948bbfe4f9ea72c6e2d4a", size = 52246, upload-time = "2026-10-13T17:02:25.282Z" }, + { url = "https://files.pythonhosted.org/packages/7e/73/347d1ab1816eb6bc5780e2df7650eb5f75398f7ef9e5b4dcbdb75199e1ae/mobilerun_core_local-0.7.2-py3-none-any.whl", hash = "sha256:62f9e53b22589f78e46bd56d1417018f547657b9b6e60642fae140f691c28f30", size = 58337, upload-time = "2026-11-03T15:58:06.562Z" }, ] [package.optional-dependencies] @@ -1586,7 +1607,7 @@ cloud = [ [[package]] name = "mobilerun-sdk" -version = "3.2.0" +version = "6.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1596,9 +1617,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/ae/e694c01c99395a8ecd2e633b60d7c4188395d12fff8dc304d4caf11572f0/mobilerun_sdk-3.2.0.tar.gz", hash = "sha256:b159845ecaf7abe94d4c92f329ae40ffbc94e3362fd34c4156d3f5fc8f7d8ccb", size = 187159, upload-time = "2026-05-21T12:31:40.396Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/32/d704aec338e7497eb49febbeae3ac9deb63c2eb6ca8c0ded5bbaf3db9501/mobilerun_sdk-6.1.1.tar.gz", hash = "sha256:b3d890788b25175497a71be913a71b000f2cf5285facd338a65a7b7c786cf94c", size = 351426, upload-time = "2026-11-10T17:01:44.02Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/dc/7baeb50a6029b05f8a70f557cc03051de0b4bbaf7c49fb4fd4cbc40351c3/mobilerun_sdk-3.2.0-py3-none-any.whl", hash = "sha256:e29cd465b743f634fe37966bc30b0e1d7aa8cf1edfba5f08a0a4d4bc84f1d1db", size = 232016, upload-time = "2026-05-21T12:31:38.769Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f3/c3f48b3615635a1e321df4a1fb61a1b7522d6a655ae99ae8c2a06ecb29aa/mobilerun_sdk-6.1.1-py3-none-any.whl", hash = "sha256:2f2858330694283dbe2e814f9232a59b84976b31361e1f260a468eaa3a7d4c3c", size = 573416, upload-time = "2026-11-10T17:01:42.687Z" }, ] [[package]] @@ -2806,6 +2827,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/a2/7840cc32890ce4b84668d3d9dfe15a48355b683ae3fb627ac97ac5a4265f/safety_schemas-0.0.16-py3-none-any.whl", hash = "sha256:6760515d3fd1e6535b251cd73014bd431d12fe0bfb8b6e8880a9379b5ab7aa44", size = 39292, upload-time = "2025-09-16T14:35:32.84Z" }, ] +[[package]] +name = "segno" +version = "1.6.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/2e/b396f750c53f570055bf5a9fc1ace09bed2dff013c73b7afec5702a581ba/segno-1.6.6.tar.gz", hash = "sha256:e60933afc4b52137d323a4434c8340e0ce1e58cec71439e46680d4db188f11b3", size = 1628586, upload-time = "2025-03-12T22:12:53.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl", hash = "sha256:28c7d081ed0cf935e0411293a465efd4d500704072cdb039778a2ab8736190c7", size = 76503, upload-time = "2025-03-12T22:12:48.106Z" }, +] + [[package]] name = "setuptools" version = "84.2.1"