From b9b8b096f789862eb37e9ee6eec36ad7bcc1b544 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 16:32:50 +0100 Subject: [PATCH 01/59] fix(cli): report command failures --- mobilerun/cli/main.py | 178 ++++------------------------------- tests/test_cli_exit_codes.py | 130 +++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 158 deletions(-) create mode 100644 tests/test_cli_exit_codes.py diff --git a/mobilerun/cli/main.py b/mobilerun/cli/main.py index 7cefa712..0838c164 100644 --- a/mobilerun/cli/main.py +++ b/mobilerun/cli/main.py @@ -280,9 +280,8 @@ async def run_command( llm = None if provider or model: - assert ( - provider and model - ), "Either both provider and model must be provided or none of them" + if not (provider and model): + raise ValueError("--provider and --model must be given together") llm_kwargs = {} if temperature is not None: llm_kwargs["temperature"] = temperature @@ -591,6 +590,9 @@ async def run( ): """Run a command on your mobile device using natural language.""" + if (provider is None) != (model is None): + raise click.UsageError("--provider and --model must be given together") + success = await run_command( command=command, config_path=config, @@ -707,6 +709,7 @@ async def devices(cloud: bool, base_url: str | None): return # Local section + local_error = False try: local = await adb.list() if not local: @@ -717,6 +720,7 @@ async def devices(cloud: bool, base_url: str | None): console.print(f" â€ĸ [bold]{device.serial}[/]") except Exception as e: console.print(f"[red]Error listing local devices: {e}[/]") + local_error = True # Cloud section (only when authenticated — silent otherwise). if resolve_cloud_api_key(): @@ -727,6 +731,9 @@ async def devices(cloud: bool, base_url: str | None): silent_errors=True, ) + if local_error: + click.get_current_context().exit(1) + DEFAULT_AUTH_URL = "https://cloud.mobilerun.ai/api/auth" DEFAULT_DEVICE_CLIENT_ID = "mobilerun-cli" @@ -830,12 +837,12 @@ 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}[/]") + raise click.ClickException(f"Error connecting to device: {e}") from e + if "connected to" in device: + console.print(f"[green]Successfully connected to {serial}[/]") + else: + raise click.ClickException(f"Failed to connect to {serial}: {device}") @cli.command() @@ -850,7 +857,7 @@ async def disconnect(serial: str): else: console.print(f"[yellow]Device {serial} was not connected[/]") except Exception as e: - console.print(f"[red]Error disconnecting from device: {e}[/]") + raise click.ClickException(f"Error disconnecting from device: {e}") from e async def _wait_for_portal_service( @@ -1071,8 +1078,7 @@ async def ping(device: str | None, tcp: bool | None, debug: bool | None): device_obj = await adb.device(device) if not device_obj: - console.print(f"[bold red]Error:[/] Could not find device {device}") - return + raise click.ClickException(f"Could not find device {device}") await ping_portal(device_obj, debug_mode) @@ -1084,12 +1090,14 @@ async def ping(device: str | None, tcp: bool | None, debug: bool | None): console.print( "[bold green]Portal is installed and accessible. You're good to go![/]" ) + except click.ClickException: + raise except Exception as e: - console.print(f"[bold red]Error:[/] {e}") if debug_mode: import traceback traceback.print_exc() + raise click.ClickException(str(e)) from e # Add macro commands as a subgroup @@ -1382,149 +1390,3 @@ def configure_xai( 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)) diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py new file mode 100644 index 00000000..3db4b8f3 --- /dev/null +++ b/tests/test_cli_exit_codes.py @@ -0,0 +1,130 @@ +from unittest.mock import AsyncMock + +from click.testing import CliRunner + +from mobilerun.cli import main + + +def test_connect_fresh_success(monkeypatch): + connect = AsyncMock(return_value="connected to 127.0.0.1:5555") + monkeypatch.setattr(main.adb, "connect", connect) + + result = CliRunner().invoke(main.cli, ["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( + main.adb, "connect", AsyncMock(return_value="already connected to device") + ) + + result = CliRunner().invoke(main.cli, ["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( + main.adb, "connect", AsyncMock(return_value="connection refused") + ) + + result = CliRunner().invoke(main.cli, ["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( + main.adb, "connect", AsyncMock(side_effect=RuntimeError("connection broke")) + ) + + result = CliRunner().invoke(main.cli, ["connect", "device"]) + + assert result.exit_code == 1 + assert "Error: Error connecting to device: connection broke" in result.output + + +def test_disconnect_exception(monkeypatch): + disconnect = AsyncMock(side_effect=RuntimeError("connection broke")) + monkeypatch.setattr(main.adb, "disconnect", disconnect) + + result = CliRunner().invoke(main.cli, ["disconnect", "device"]) + + assert result.exit_code == 1 + assert "Error: Error disconnecting from device: connection broke" in result.output + disconnect.assert_awaited_once_with("device", raise_error=True) + + +def test_disconnect_not_connected_is_success(monkeypatch): + monkeypatch.setattr(main.adb, "disconnect", AsyncMock(return_value=False)) + + result = CliRunner().invoke(main.cli, ["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(main.adb, "device", AsyncMock(return_value=None)) + + result = CliRunner().invoke(main.cli, ["ping", "-d", "no-such-device"]) + + assert result.exit_code == 1 + assert "Error: Could not find device no-such-device" in result.output + + +def test_ping_driver_error(monkeypatch): + monkeypatch.setattr(main.adb, "device", AsyncMock(return_value=object())) + monkeypatch.setattr( + main, "ping_portal", AsyncMock(side_effect=RuntimeError("portal broke")) + ) + + result = CliRunner().invoke(main.cli, ["ping", "-d", "device", "--debug"]) + + assert result.exit_code == 1 + assert "Error: portal broke" in result.output + assert "Traceback (most recent call last):" in result.output + + +def test_devices_local_error_still_lists_cloud(monkeypatch): + monkeypatch.setattr( + main.adb, "list", AsyncMock(side_effect=RuntimeError("adb broke")) + ) + monkeypatch.setattr(main, "resolve_cloud_api_key", lambda: "test-token") + cloud_listing = AsyncMock(return_value=0) + monkeypatch.setattr(main, "_list_cloud_devices", cloud_listing) + + result = CliRunner().invoke(main.cli, ["devices"]) + + assert result.exit_code == 1 + assert "Error listing local devices: adb broke" in result.output + cloud_listing.assert_awaited_once_with( + None, silent_if_no_key=True, silent_errors=True + ) + + +def test_run_provider_without_model(monkeypatch): + run_command = AsyncMock() + monkeypatch.setattr(main, "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(main, "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() From d1ac7503fe9ef569b5ad388e8a2cbec772847c3d Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 17:28:30 +0100 Subject: [PATCH 02/59] chore(deps): upgrade device packages --- compat/pyproject.toml | 12 ++++++------ pyproject.toml | 7 ++++--- uv.lock | 45 ++++++++++++++++++++++++++++++++++--------- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/compat/pyproject.toml b/compat/pyproject.toml index 8ab8f2cc..9a60816f 100644 --- a/compat/pyproject.toml +++ b/compat/pyproject.toml @@ -1,15 +1,15 @@ [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"] -dev = ["mobilerun[dev]==0.7.4"] +anthropic = ["mobilerun[anthropic]==0.8.0"] +deepseek = ["mobilerun[deepseek]==0.8.0"] +langfuse = ["mobilerun[langfuse]==0.8.0"] +dev = ["mobilerun[dev]==0.8.0"] [project.scripts] droidrun = "droidrun.cli_shim:main" diff --git a/pyproject.toml b/pyproject.toml index a048b247..a15e0c0c 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 = [ @@ -24,8 +24,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.11.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/uv.lock b/uv.lock index 9ad83209..37207b1f 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,14 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'win32'", ] +[options] +exclude-newer = "2026-11-04T16:27:30Z" + +[options.exclude-newer-package] +mobilerun-core = "2026-11-11T16:27:30Z" +mobilerun-core-local = "2026-11-11T16:27:30Z" +mobilerun-sdk = "2026-11-11T16:27:30Z" + [manifest] overrides = [{ name = "filelock", specifier = ">=4.2.1" }] @@ -1399,7 +1407,7 @@ wheels = [ [[package]] name = "mobilerun" -version = "0.7.4" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, @@ -1420,6 +1428,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" }, @@ -1492,8 +1501,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.11.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" }, @@ -1521,9 +1531,26 @@ dev = [ { name = "safety", specifier = ">=3.2.11" }, ] +[[package]] +name = "mobilerun-core" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mobilerun-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/b8/e19c3cffcffe27448358f54201f9993a976dfb5f44fb4ab6e6c0ff6c1713/mobilerun_core-1.11.0.tar.gz", hash = "sha256:58f7bf489b30010f863ea018d3d9ceaf11ed1f3892318a517bc237c4765975d8", size = 116310, upload-time = "2026-11-10T00:33:42.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/54/6364415dd45443a303cd0d11ceba59e2f9299b8db86dd6382e48c91bf1d0/mobilerun_core-1.11.0-py3-none-any.whl", hash = "sha256:875513f2ad9e5608a0b64f6e9ccb85d60d208f1f87a6d7cc8ae358242ad32598", size = 61017, upload-time = "2026-11-10T00:33:40.851Z" }, +] + +[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" }, @@ -1531,9 +1558,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] @@ -1543,7 +1570,7 @@ cloud = [ [[package]] name = "mobilerun-sdk" -version = "3.2.0" +version = "6.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1553,9 +1580,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]] From 7e5164e5e32acb36bbe7a8ae52d4c0041822dbcd Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 17:49:07 +0100 Subject: [PATCH 03/59] feat(auth): group provider sign-ins --- mobilerun/cli/auth.py | 335 ++++++++++++++++++++++++++++++ mobilerun/cli/configure.py | 53 +++++ mobilerun/cli/configure_wizard.py | 17 +- mobilerun/cli/options.py | 150 +++++++++++++ mobilerun/cli/providers.py | 41 ++++ 5 files changed, 593 insertions(+), 3 deletions(-) create mode 100644 mobilerun/cli/auth.py create mode 100644 mobilerun/cli/configure.py create mode 100644 mobilerun/cli/options.py create mode 100644 mobilerun/cli/providers.py diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py new file mode 100644 index 00000000..3849e826 --- /dev/null +++ b/mobilerun/cli/auth.py @@ -0,0 +1,335 @@ +""" +Mobilerun CLI - Command line interface for controlling Android devices through LLM agents. +""" + +import math +import os +from pathlib import Path + +import click +from rich.console import Console + +from mobilerun.cli import deviceauth +from mobilerun.cli.configure_wizard import _OAUTH_CREDENTIAL_SLOTS, _oauth_credentials_present +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.cli.options import ( + Group, + auth_url_option, + emit, + json_option, + model_option, + verbose_option, +) +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: + 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) + + +DEFAULT_AUTH_URL = "https://cloud.mobilerun.ai/api/auth" +DEFAULT_DEVICE_CLIENT_ID = "mobilerun-cli" + + +def _login_mobilerun(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 + + 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"), +} +service_argument = click.argument( + "service", type=click.Choice(["mobilerun", *SERVICES]), default="mobilerun" +) + + +@click.group(cls=Group) +@verbose_option +def auth_cli(verbose): + """Sign in, sign out, and inspect saved credentials.""" + + +@auth_cli.command() +@service_argument +@verbose_option +@auth_url_option +@model_option +@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." +) +@click.option( + "--timeout", + type=float, + default=None, + callback=_validate_oauth_timeout, + help="Login timeout in seconds (default 300).", +) +@click.option("--no-browser", is_flag=True, help="Do not open a browser.") +@click.option("--callback-host", help="OAuth callback host.") +@click.option("--callback-port", type=click.IntRange(0, 65535), help="OAuth callback port.") +@click.option("--callback-path", help="OAuth callback path.") +@click.option("--device-code", is_flag=True, help="Use xAI device-code login.") +@click.option("--token", help="Save an Anthropic setup-token.") +@click.pass_context +def login( + ctx, + service, + verbose, + auth_url, + model, + client_id, + credential_path, + timeout, + no_browser, + callback_host, + callback_port, + callback_path, + device_code, + token, +): + """Log in to SERVICE (default mobilerun).""" + supported = { + "mobilerun": {"auth_url", "client_id"}, + "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"}, + } + for name in ( + "auth_url", + "client_id", + "credential_path", + "timeout", + "no_browser", + "callback_host", + "callback_port", + "callback_path", + "device_code", + "token", + "model", + ): + if ( + ctx.get_parameter_source(name) == click.core.ParameterSource.COMMANDLINE + and name not in supported[service] + ): + raise click.UsageError(f"--{name.replace('_', '-')} is not supported for {service}") + if service == "mobilerun": + _login_mobilerun(auth_url or DEFAULT_AUTH_URL, client_id or DEFAULT_DEVICE_CLIENT_ID) + return + kwargs = { + "credential_path": credential_path or str(SERVICES[service][0]), + "timeout": timeout or 300.0, + "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() +@service_argument +@verbose_option +def logout(service, verbose): + """Remove SERVICE's stored credential, preserving other services.""" + 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}.") + + +@auth_cli.command() +@verbose_option +@json_option +@auth_url_option +def status(verbose, json_output, auth_url): + """Show saved sign-in status without exposing tokens.""" + credentials = deviceauth.load_credentials() or {} + token = credentials.get("access_token") + rows = [ + { + "service": "mobilerun", + "signed_in": bool(token), + "credential_path": str(deviceauth.credential_path()), + "email": None, + } + ] + failed = False + if token: + try: + session = deviceauth.fetch_session( + auth_url or 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 + if failed: + rows[0]["signed_in"] = False + rows[0]["error"] = "session check failed" + for service, (path, variant) in SERVICES.items(): + rows.append( + { + "service": service, + "signed_in": _oauth_credentials_present(str(path), variant), + "credential_path": str(path), + } + ) + if json_output: + emit(rows, json_output=True) + else: + for row in rows: + label = "signed in" if row["signed_in"] else "not signed in" + email = f" ({row['email']})" if row.get("email") else "" + emit(f"{row['service']}: {label}{email} {row['credential_path']}") + if failed: + raise click.ClickException( + "Mobilerun session check failed; run `mobilerun auth login` again." + ) diff --git a/mobilerun/cli/configure.py b/mobilerun/cli/configure.py new file mode 100644 index 00000000..da70c884 --- /dev/null +++ b/mobilerun/cli/configure.py @@ -0,0 +1,53 @@ +"""Interactive configuration command.""" + +import click +from rich.console import Console + +from mobilerun.cli.auth import ( + _run_anthropic_oauth_login, + _run_gemini_oauth_login, + _run_grok_oauth_login, + _run_openai_oauth_login, +) +from mobilerun.cli.configure_wizard import ConfigureWizardCallbacks, run_configure_wizard +from mobilerun.cli.options import ( + CONTEXT_SETTINGS, + Command, + config_option, + llm_url_option, + model_option, + verbose_option, +) +from mobilerun.cli.providers import PROVIDERS, provider_option + + +@click.command(cls=Command, context_settings=CONTEXT_SETTINGS) +@provider_option +@model_option +@llm_url_option +@verbose_option +@config_option +@click.option("-k", "--api-key", help="API key for the selected provider.") +@click.option("--auth-mode", help="Authentication mode for the provider.") +def configure(provider, model, llm_url, verbose, config_path, api_key, auth_mode): + """Configure provider, authentication and model with the wizard.""" + spec = PROVIDERS.get(provider) + if provider and provider.endswith("-oauth"): + if auth_mode and auth_mode != "oauth": + raise click.UsageError("The selected provider requires --auth-mode oauth") + auth_mode = "oauth" + 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, + ) diff --git a/mobilerun/cli/configure_wizard.py b/mobilerun/cli/configure_wizard.py index e4f1dcf0..18ba1db7 100644 --- a/mobilerun/cli/configure_wizard.py +++ b/mobilerun/cli/configure_wizard.py @@ -684,8 +684,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() @@ -729,7 +730,7 @@ def run_configure_wizard( base_url=base_url, ) if provider_configured: - ConfigLoader.save(config) + _save_config(config, config_path) _print_configure_summary( console, family_id=state.family_id or "", @@ -785,7 +786,7 @@ def run_configure_wizard( _configure_advanced_settings(console, config) elif action == "finish": - ConfigLoader.save(config) + _save_config(config, config_path) if provider_configured and state.family_id: _print_configure_summary( console, @@ -799,3 +800,13 @@ def run_configure_wizard( else: console.print("[green]Configuration saved.[/]") return + + +def _save_config(config, config_path): + if config_path is None: + return ConfigLoader.save(config) + from mobilerun.config_manager.migrations import CURRENT_VERSION + + data = config.to_dict() + data["_version"] = CURRENT_VERSION + return ConfigLoader._save_dict(data, Path(config_path)) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py new file mode 100644 index 00000000..5d8a49f4 --- /dev/null +++ b/mobilerun/cli/options.py @@ -0,0 +1,150 @@ +"""Shared Click options and command IO policy.""" + +import asyncio +import json +import sys +from contextlib import redirect_stdout +from functools import wraps + +import click + +DEFAULT_API_URL = "https://api.mobilerun.ai/v1" +CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} + + +class Command(click.Command): + def invoke(self, ctx): + ctx.ensure_object(dict) + ctx.obj["stdout"] = sys.stdout + if "verbose" in ctx.params and ctx.params["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 + import logging + + from mobilerun.log_handlers import configure_logging + + logger = logging.getLogger("mobilerun") + previous = logger.handlers[:], logger.level, logger.propagate + configure_logging(bool(ctx.params.get("verbose")), logging.StreamHandler(sys.stderr)) + try: + # Libraries and OAuth flows may print progress directly. + with redirect_stdout(sys.stderr): + return super().invoke(ctx) + except (KeyboardInterrupt, asyncio.CancelledError, click.Abort): + ctx.exit(130) + except (click.ClickException, SystemExit): + raise + except Exception as exc: + raise click.ClickException(str(exc) or type(exc).__name__) from exc + finally: + logger.handlers, level, logger.propagate = previous + logger.setLevel(level) + + +class Group(click.Group): + command_class = Command + group_class = type + + def __init__(self, *args, **kwargs): + kwargs.setdefault("context_settings", CONTEXT_SETTINGS) + super().__init__(*args, **kwargs) + + +# The same declarations are reused everywhere, including groups. +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=None, + envvar="MOBILERUN_AUTH_URL", + show_envvar=True, + help="Sign-in URL (default https://cloud.mobilerun.ai/api/auth).", +) +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, + tcp_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 = ctx.obj.get("stdout") if ctx and ctx.obj else sys.stdout + click.echo(json.dumps(value, default=str) if json_output else value, file=output) diff --git a/mobilerun/cli/providers.py b/mobilerun/cli/providers.py new file mode 100644 index 00000000..3802cc7e --- /dev/null +++ b/mobilerun/cli/providers.py @@ -0,0 +1,41 @@ +"""Public provider spellings shared by run and the configuration wizard.""" + +from dataclasses import dataclass + +import click + + +@dataclass(frozen=True) +class Provider: + runtime: str + family: str + auth_mode: str + url_keyword: str + + +PROVIDERS = { + "openai": Provider("OpenAIResponses", "openai", "api_key", "api_base"), + "openai-oauth": Provider("openai_oauth", "openai", "oauth", "api_base"), + "anthropic": Provider("Anthropic", "anthropic", "api_key", "base_url"), + "anthropic-oauth": Provider("anthropic_oauth", "anthropic", "oauth", "api_base"), + "gemini": Provider("GoogleGenAI", "gemini", "api_key", "http_options"), + "gemini-oauth": Provider("gemini_oauth_code_assist", "gemini", "oauth", "code_assist_endpoint"), + "xai": Provider("XAI", "xai", "api_key", "api_base"), + "ollama": Provider("Ollama", "ollama", "none", "base_url"), + "openai-like": Provider("OpenAILike", "openai_like", "api_key", "api_base"), + "minimax": Provider("MiniMax", "minimax", "api_key", "api_base"), + "zai": Provider("ZAI", "zai", "api_key", "api_base"), +} +provider_option = click.option( + "-p", + "--provider", + type=click.Choice(list(PROVIDERS), case_sensitive=False), + help="LLM provider.", +) + + +def llm_url_kwargs(runtime, url): + if url is None: + return {} + spec = next(spec for spec in PROVIDERS.values() if spec.runtime == runtime) + return {spec.url_keyword: {"base_url": url} if spec.url_keyword == "http_options" else url} From 7a296addac2273bdf29ab791219ba5ab125a840b Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 17:50:36 +0100 Subject: [PATCH 04/59] feat(cli): unify command surface --- mobilerun/cli/__init__.py | 13 +- mobilerun/cli/device_commands.py | 330 +++---- mobilerun/cli/device_lifecycle.py | 121 +++ mobilerun/cli/doctor.py | 31 +- mobilerun/cli/doctor_command.py | 49 + mobilerun/cli/env.py | 84 ++ mobilerun/cli/main.py | 1406 +---------------------------- mobilerun/cli/run.py | 443 +++++++++ mobilerun/cli/selection.py | 83 ++ mobilerun/cli/setup.py | 172 ++++ mobilerun/macro/cli.py | 120 +-- 11 files changed, 1208 insertions(+), 1644 deletions(-) create mode 100644 mobilerun/cli/device_lifecycle.py create mode 100644 mobilerun/cli/doctor_command.py create mode 100644 mobilerun/cli/env.py create mode 100644 mobilerun/cli/run.py create mode 100644 mobilerun/cli/selection.py create mode 100644 mobilerun/cli/setup.py diff --git a/mobilerun/cli/__init__.py b/mobilerun/cli/__init__.py index 78b2a428..791480ff 100644 --- a/mobilerun/cli/__init__.py +++ b/mobilerun/cli/__init__.py @@ -1,9 +1,10 @@ -""" -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/device_commands.py b/mobilerun/cli/device_commands.py index 2a0eef97..6b5b2d3a 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -8,11 +8,8 @@ import json import logging import os -import re import sys import tempfile -from functools import wraps -from typing import Optional import click from async_adbutils import adb @@ -21,19 +18,16 @@ 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.agent.utils.portal_setup import portal_version_kwargs -from mobilerun.config_manager import ConfigLoader +from mobilerun.cli.options import Group, coro, device_options, emit, json_option, verbose_option +from mobilerun.cli.selection import core_client, resolve_cloud_api_key, select_device from mobilerun.log_handlers import configure_logging from mobilerun.tools.filters import ConciseFilter from mobilerun.tools.formatters import IndexedFormatter @@ -45,164 +39,91 @@ DEFAULT_CLOUD_BASE_URL = "https://api.mobilerun.ai/v1" 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, -) -def _looks_like_cloud_device_id(value: Optional[str]) -> bool: - return bool(value and _CLOUD_DEVICE_ID_RE.match(value)) +class CoreDriver: + """Expose the core facade through the framework's async state-provider interface.""" + def __init__(self, device, backend): + self.core = device + self.backend = backend -def resolve_cloud_api_key() -> Optional[str]: - """Resolve the Mobilerun cloud API key for ``--cloud`` commands. + async def screenshot(self): + import base64 + from pathlib import Path - Order: the ``MOBILERUN_CLOUD_API_KEY`` env var, then the credential persisted by - ``mobilerun login`` (``/credentials/mobilerun-cloud.json``). - """ - env_key = os.environ.get(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 + value = await asyncio.to_thread(self.core.screenshot) + if self.backend == "cloud": + path = Path(value) + try: + return path.read_bytes() + finally: + path.unlink(missing_ok=True) + return base64.b64decode(value) - 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 + async def get_ui_tree(self, **kwargs): + return await asyncio.to_thread(self.core.ui) + + async def tap(self, x, y): + await asyncio.to_thread(self.core.tap, x, y, humanize=False) + + async def swipe(self, x1, y1, x2, y2, duration_ms=1000): + await asyncio.to_thread(self.core.swipe, x1, y1, x2, y2, ms=duration_ms, humanize=False) + + async def input_text(self, text, clear=False): + await asyncio.to_thread(self.core.type, text, clear=clear, humanize=False) + return True - config = ConfigLoader.load(config_path) + async def press_button(self, button): + await asyncio.to_thread(self.core.key, button) - 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" + async def get_apps(self, include_system=False): + return await asyncio.to_thread(self.core.list_apps, include_system_apps=include_system) - is_ios = config.device.platform.lower() == "ios" + async def start_app(self, package): + await asyncio.to_thread(self.core.start_app, package) + return f"Started {package}" - if is_ios: - if config.device.serial: - url = validate_ios_portal_url(config.device.serial) + +async def connect_driver(device=None, config_path=None, tcp=None, platform=None, + target="local", api_url=DEFAULT_CLOUD_BASE_URL, verbose=None, framework=False): + _log_progress_to_stderr() + selection = await select_device(device, config_path, tcp, platform, target) + config = selection.config + config.device.serial = selection.device + config.device.platform = selection.platform + if selection.backend == "local-android-adb": + # The facade does not expose auto-setup, explicit TCP, or keyboard teardown. + return await _connect_local_driver(selection.device, config, tcp, False) + client = core_client(api_url) + if selection.backend == "cloud" and not resolve_cloud_api_key(): + raise click.ClickException( + "No cloud API key found. Set MOBILERUN_CLOUD_API_KEY or run `mobilerun auth login`." + ) + if framework: + # Preserve input_text's boolean failure result and the macro engine's + # async driver verbs (core.type discards the underlying boolean). + if selection.backend == "cloud": + from mobilerun_core_local.driver.cloud import CloudDriver + + driver = CloudDriver(device_id=selection.device, api_key=resolve_cloud_api_key(), base_url=api_url) + elif selection.backend == "local-ios-http": + driver = await create_ios_driver(selection.device, token=config.device.resolve_auth_token()) else: - url = await discover_ios_device() - driver = await create_ios_driver(url, token=config.device.resolve_auth_token()) + driver = create_android_portal_http_driver(selection.device, config.device.resolve_auth_token()) await driver.connect() - return driver, True + return driver, selection.platform == "ios" + connected = client.connect( + selection.device, backend=selection.backend, platform=selection.platform, + url=selection.device if is_portal_http_url(selection.device) else None, + token=config.device.resolve_auth_token(), + ) + return CoreDriver(connected, selection.backend), selection.platform == "ios" - serial = config.device.serial - if is_portal_http_url(serial): - try: - driver = create_android_portal_http_driver( - serial, config.device.resolve_auth_token() - ) - 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) +async def _connect_local_driver(device, config, tcp, ios): + serial = device if config.device.auto_setup and config.device.portal_mode != "disabled": device_obj = await adb.device(serial=serial) await ensure_portal_ready( @@ -252,8 +173,9 @@ async def _teardown_android(driver): # --------------------------------------------------------------------------- -@click.group() -def device_cli(): +@click.group(cls=Group) +@verbose_option +def device_cli(verbose): """Direct device actions (screenshot, tap, swipe, etc.).""" pass @@ -264,12 +186,13 @@ def device_cli(): @device_cli.command() +@json_option @device_options @coro -async def screenshot(device, config_path, tcp, ios, cloud, device_id, base_url): +async def screenshot(device, config_path, tcp, platform, target, api_url, verbose, json_output): """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 + driver, _ = await connect_driver( + device, config_path, tcp, platform, target, api_url, verbose ) try: png_bytes = await driver.screenshot() @@ -278,21 +201,30 @@ async def screenshot(device, config_path, tcp, ios, cloud, device_id, base_url): os.write(fd, png_bytes) finally: os.close(fd) - click.echo(path) + emit({"path": path} if json_output else path, json_output=json_output) finally: await _teardown_android(driver) @device_cli.command() +@json_option @device_options @coro -async def ui(device, config_path, tcp, ios, cloud, device_id, base_url): +async def ui(device, config_path, tcp, platform, target, api_url, verbose, json_output): """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 + driver, is_ios = await connect_driver( + device, config_path, tcp, platform, target, api_url, verbose ) try: + if isinstance(driver, CoreDriver): + state = await driver.get_ui_tree() + if json_output: + emit(state, json_output=True) + else: + tree = state.get("a11y_tree", state) + emit(tree if isinstance(tree, str) else json.dumps(state, indent=2)) + return if is_ios and not isinstance(driver, IOSPortalHttpDriver): provider = IOSStateProvider(driver) else: @@ -302,9 +234,12 @@ async def ui(device, config_path, tcp, ios, cloud, device_id, base_url): 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}") + if json_output: + emit({"ui": state.formatted_text, "phone_state": state.phone_state}, json_output=True) + else: + emit(state.formatted_text) + if state.phone_state: + emit(f"\nPhone state: {state.phone_state}") finally: await _teardown_android(driver) @@ -314,14 +249,14 @@ async def ui(device, config_path, tcp, ios, cloud, device_id, base_url): @click.argument("y", type=int) @device_options @coro -async def tap(x, y, device, config_path, tcp, ios, cloud, device_id, base_url): +async def tap(x, y, device, config_path, tcp, platform, target, api_url, verbose): """Tap at screen coordinates.""" - driver, _ = await _create_driver( - device, config_path, tcp, ios, cloud, device_id, base_url + driver, _ = await connect_driver( + device, config_path, tcp, platform, target, api_url, verbose ) try: await driver.tap(x, y) - click.echo(f"Tapped ({x}, {y})") + click.echo(f"Tapped ({x}, {y})", err=True) finally: await _teardown_android(driver) @@ -337,15 +272,15 @@ async def tap(x, y, device, config_path, tcp, ios, cloud, device_id, base_url): @device_options @coro async def swipe_cmd( - x1, y1, x2, y2, duration, device, config_path, tcp, ios, cloud, device_id, base_url + x1, y1, x2, y2, duration, device, config_path, tcp, platform, target, api_url, verbose ): """Swipe from (x1, y1) to (x2, y2).""" - driver, _ = await _create_driver( - device, config_path, tcp, ios, cloud, device_id, base_url + driver, _ = await connect_driver( + device, config_path, tcp, platform, target, api_url, verbose ) try: await driver.swipe(x1, y1, x2, y2, duration_ms=int(duration * 1000)) - click.echo(f"Swiped ({x1}, {y1}) -> ({x2}, {y2})") + click.echo(f"Swiped ({x1}, {y1}) -> ({x2}, {y2})", err=True) finally: await _teardown_android(driver) @@ -355,16 +290,18 @@ async def swipe_cmd( @click.argument("y", type=int) @device_options @coro -async def long_press(x, y, device, config_path, tcp, ios, cloud, device_id, base_url): +async def long_press(x, y, device, config_path, tcp, platform, target, api_url, verbose): """Long press at screen coordinates.""" - if ios: + if platform == "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 + driver, is_ios = await connect_driver( + device, config_path, tcp, platform, target, api_url, verbose ) try: + if is_ios: + raise click.ClickException("long-press is not supported on iOS") await driver.swipe(x, y, x, y, 1000) - click.echo(f"Long pressed ({x}, {y})") + click.echo(f"Long pressed ({x}, {y})", err=True) finally: await _teardown_android(driver) @@ -375,16 +312,16 @@ async def long_press(x, y, device, config_path, tcp, ios, cloud, device_id, base @device_options @coro async def type_text( - text, clear, device, config_path, tcp, ios, cloud, device_id, base_url + text, clear, device, config_path, tcp, platform, target, api_url, verbose ): """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 + driver, _ = await connect_driver( + device, config_path, tcp, platform, target, api_url, verbose, framework=True ) try: success = await driver.input_text(text, clear) if success: - click.echo(f"Typed: {text}") + click.echo(f"Typed: {text}", err=True) else: raise click.ClickException("Failed to type text") finally: @@ -397,41 +334,47 @@ async def type_text( ) @device_options @coro -async def press(button, device, config_path, tcp, ios, cloud, device_id, base_url): +async def press(button, device, config_path, tcp, platform, target, api_url, verbose): """Press a system button.""" - driver, _ = await _create_driver( - device, config_path, tcp, ios, cloud, device_id, base_url + driver, _ = await connect_driver( + device, config_path, tcp, platform, target, api_url, verbose ) try: await driver.press_button(button) - click.echo(f"Pressed {button}") + click.echo(f"Pressed {button}", err=True) finally: await _teardown_android(driver) @device_cli.command() @click.option("--system/--no-system", default=False, help="Include system apps") +@json_option @device_options @coro -async def apps(system, device, config_path, tcp, ios, cloud, device_id, base_url): +async def apps(system, device, config_path, tcp, platform, target, api_url, verbose, json_output): """List installed apps.""" - driver, _ = await _create_driver( - device, config_path, tcp, ios, cloud, device_id, base_url + driver, _ = await connect_driver( + device, config_path, tcp, platform, target, api_url, verbose ) try: app_list = await driver.get_apps(include_system=system) + if json_output: + emit(app_list, json_output=True) + return for app in app_list: label = app.get("label") or "" package = ( app.get("package") or app.get("package_name") or app.get("packageName") + or app.get("app_id") + or app.get("bundle_id") or "" ) if label and label != package: - click.echo(f"{package} ({label})") + emit(f"{package} ({label})") else: - click.echo(package) + emit(package) finally: await _teardown_android(driver) @@ -440,13 +383,14 @@ async def apps(system, device, config_path, tcp, ios, cloud, device_id, base_url @click.argument("package") @device_options @coro -async def start(package, device, config_path, tcp, ios, cloud, device_id, base_url): +async def start(package, device, config_path, tcp, platform, target, api_url, verbose): """Launch an app by package name.""" - driver, _ = await _create_driver( - device, config_path, tcp, ios, cloud, device_id, base_url + driver, _ = await connect_driver( + device, config_path, tcp, platform, target, api_url, verbose ) try: result = await driver.start_app(package) - click.echo(result) + click.echo(result, err=True) finally: await _teardown_android(driver) + diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py new file mode 100644 index 00000000..5de16458 --- /dev/null +++ b/mobilerun/cli/device_lifecycle.py @@ -0,0 +1,121 @@ +"""Device discovery, connection and Portal setup commands.""" + +import asyncio + +import click +from async_adbutils import adb +from mobilerun_core_local.driver.android.portal import ( + ping_portal, + ping_portal_content, + ping_portal_tcp, +) + +from mobilerun.cli.device_commands import _teardown_android, connect_driver, device_cli +from mobilerun.cli.options import coro, device_options, emit, json_option +from mobilerun.cli.selection import core_client, resolve_cloud_api_key, select_device +from mobilerun.cli.setup import _setup_portal + + +@device_cli.command("list") +@device_options +@json_option +@click.option("-a", "--all", "all_devices", is_flag=True, help="List local and cloud devices.") +@coro +async def list_devices( + device, config_path, tcp, platform, target, api_url, verbose, json_output, all_devices +): + """List devices in the selected target.""" + scope = "all" if all_devices else target + if scope in {"cloud", "all"} and not resolve_cloud_api_key(): + raise click.ClickException( + "No cloud API key found. Set MOBILERUN_CLOUD_API_KEY or run `mobilerun auth login`." + ) + devices = await asyncio.to_thread(core_client(api_url).list_devices, scope=scope) + rows = [{"device_id": item.device_id, "capabilities": item.capabilities} for item in devices] + if json_output: + emit(rows, json_output=True) + elif rows: + for row in rows: + emit(row["device_id"]) + else: + click.echo("No devices found.", err=True) + + +@device_cli.command() +@click.argument("serial", required=False) +@device_options +@coro +async def connect(serial, device, config_path, tcp, platform, target, api_url, verbose): + """Connect to an Android device over TCP/IP.""" + selection = device or serial + if not selection: + raise click.UsageError("Supply SERIAL or --device") + if target == "cloud" or platform == "ios": + raise click.UsageError("TCP connect requires a local Android device") + result = await adb.connect(selection) + if "connected to" not in result: + raise click.ClickException(f"Failed to connect to {selection}: {result}") + click.echo(f"Successfully connected to {selection}", err=True) + + +@device_cli.command() +@click.argument("serial", required=False) +@device_options +@coro +async def disconnect(serial, device, config_path, tcp, platform, target, api_url, verbose): + """Disconnect an Android TCP/IP device.""" + selection = device or serial + if not selection: + raise click.UsageError("Supply SERIAL or --device") + if target == "cloud" or platform == "ios": + raise click.UsageError("TCP disconnect requires a local Android device") + success = await adb.disconnect(selection, raise_error=True) + click.echo( + f"Successfully disconnected from {selection}" + if success + else f"Device {selection} was not connected", + err=True, + ) + + +@device_cli.command() +@device_options +@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, tcp, platform, target, api_url, verbose, apk, portal_version, latest +): + """Install and enable the Portal on a local Android device.""" + 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, tcp, platform, target) + if selection.backend != "local-android-adb": + raise click.UsageError("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() +@device_options +@coro +async def ping(device, config_path, tcp, platform, target, api_url, verbose): + """Check whether a device and its Portal are reachable.""" + selection = await select_device(device, config_path, tcp, platform, target) + if selection.backend == "local-android-adb": + obj = await adb.device(selection.device) + if not obj: + raise click.ClickException(f"Could not find device {selection.device}") + await ping_portal(obj, bool(verbose)) + check = ping_portal_tcp if selection.config.device.use_tcp else ping_portal_content + await check(obj, bool(verbose)) + else: + driver, _ = await connect_driver( + device, config_path, tcp, platform, target, api_url, verbose + ) + try: + await driver.get_ui_tree() + finally: + await _teardown_android(driver) + click.echo("Device is reachable and serving the UI tree.", err=True) diff --git a/mobilerun/cli/doctor.py b/mobilerun/cli/doctor.py index 398759d2..9f42d131 100644 --- a/mobilerun/cli/doctor.py +++ b/mobilerun/cli/doctor.py @@ -26,7 +26,7 @@ from mobilerun import __version__ -console = Console() +console = Console(stderr=True) PORTAL_REMOTE_PORT = 8080 @@ -131,17 +131,20 @@ 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.""" + import os + from pathlib import Path + from mobilerun.config_manager import ConfigLoader from mobilerun.config_manager.loader import OutdatedConfigError - config_path = ConfigLoader.get_user_config_path() + config_path = Path(config_path or os.environ.get("MOBILERUN_CONFIG") or ConfigLoader.get_user_config_path()) if not config_path.exists(): # Will be created on first load try: - config = ConfigLoader.load() + config = ConfigLoader.load(str(config_path)) return ( CheckResult( "Config", @@ -162,7 +165,7 @@ async def check_config(debug: bool) -> tuple[CheckResult, Any]: ) try: - config = ConfigLoader.load() + config = ConfigLoader.load(str(config_path)) return CheckResult("Config", Status.PASS, str(config_path)), config except OutdatedConfigError: return ( @@ -382,7 +385,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,7 +677,8 @@ 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, +) -> list[CheckResult]: """Run all doctor checks and auto-fix where possible.""" debug = debug or False @@ -688,7 +692,7 @@ async def run_doctor( results.append(r) # 2. Config - r_config, config = await check_config(debug) + r_config, config = await check_config(debug, config_path) _print_result(r_config, debug) results.append(r_config) @@ -702,7 +706,7 @@ async def run_doctor( results.append(r) if r.status == Status.FAIL: _print_summary(results) - return + return results # 5. Device r_device, device = await check_device(device_serial, debug) @@ -711,14 +715,14 @@ async def run_doctor( if device is None: console.print("\n [dim]Skipping device-dependent checks (no device)[/]") _print_summary(results) - return + return results # 6. Portal Installed r_portal, is_installed = await check_portal_installed(device, debug) _print_result(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 @@ -726,7 +730,7 @@ async def run_doctor( if not is_installed: results.append(r_portal) _print_summary(results) - return + return results results.append(r_portal) # 7. Portal Version @@ -735,7 +739,7 @@ async def run_doctor( 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 @@ -803,6 +807,7 @@ async def run_doctor( results.append(r) _print_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..35f36845 --- /dev/null +++ b/mobilerun/cli/doctor_command.py @@ -0,0 +1,49 @@ +"""Click wiring for health checks.""" + +from dataclasses import asdict + +import click + +from mobilerun.cli.device_commands import _teardown_android, connect_driver +from mobilerun.cli.doctor import CheckResult, Status, check_config, check_sdk_version, run_doctor +from mobilerun.cli.options import CONTEXT_SETTINGS, Command, coro, device_options, emit, json_option +from mobilerun.cli.selection import select_device + + +@click.command(cls=Command, context_settings=CONTEXT_SETTINGS) +@device_options +@json_option +@coro +async def doctor(device, config_path, tcp, platform, target, api_url, verbose, json_output): + """Check system and device health; fail if any check fails.""" + try: + selection = await select_device(device, config_path, tcp, platform, target) + except Exception as exc: + results = [CheckResult("Device", Status.FAIL, str(exc))] + else: + if selection.backend == "local-android-adb": + results = await run_doctor(selection.device, bool(verbose), config_path) + else: + config_result, _ = await check_config(bool(verbose), config_path) + results = [await check_sdk_version(bool(verbose)), config_result] + try: + driver, _ = await connect_driver( + device, config_path, tcp, platform, target, api_url, verbose + ) + try: + await driver.get_ui_tree() + finally: + await _teardown_android(driver) + results.append(CheckResult("Device", Status.PASS, "UI tree is reachable")) + except Exception as exc: + results.append(CheckResult("Device", Status.FAIL, str(exc))) + if json_output: + emit( + [{**asdict(result), "status": result.status.value} for result in results], + json_output=True, + ) + else: + for result in results: + emit(f"{result.name}: {result.status.value}: {result.message}") + if any(result.status == Status.FAIL for result in results): + raise click.ClickException("Health checks failed") diff --git a/mobilerun/cli/env.py b/mobilerun/cli/env.py new file mode 100644 index 00000000..f39801ed --- /dev/null +++ b/mobilerun/cli/env.py @@ -0,0 +1,84 @@ +"""Inspect resolved CLI environment values and their sources.""" + +import os +from pathlib import Path + +import click + +from mobilerun.cli import deviceauth +from mobilerun.cli.auth import DEFAULT_AUTH_URL +from mobilerun.cli.options import ( + CONTEXT_SETTINGS, + DEFAULT_API_URL, + Command, + config_option, + emit, + json_option, + verbose_option, +) +from mobilerun.cli.selection import resolve_cloud_api_key +from mobilerun.config_manager import ConfigLoader, MobileConfig +from mobilerun.config_manager.env_keys import API_KEY_ENV_VARS, load_env_key_sources + + +def resolved_environment(config_path=None): + path = Path( + config_path or os.environ.get("MOBILERUN_CONFIG") or ConfigLoader.get_user_config_path() + ) + exists = path.exists() + config = ConfigLoader.load(str(path)) if exists or config_path or os.environ.get("MOBILERUN_CONFIG") else MobileConfig() + config_source = "config" if exists else "default" + credentials = deviceauth.load_credentials() or {} + values = { + "MOBILERUN_TARGET": ("local", "default", False), + "MOBILERUN_DEVICE": (config.device.serial, config_source, False), + "MOBILERUN_CLOUD_API_KEY": ( + resolve_cloud_api_key(), + "credential file" if resolve_cloud_api_key() else "default", + 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), "default", False), + "MOBILERUN_DEVICE_TOKEN": (config.device.auth_token, config_source, True), + "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), + } + 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) + rows = [] + for name, (value, source, secret) in values.items(): + if os.environ.get(name): + value, source = os.environ[name], "env" + if secret: + value = "set" if value else "not set" + rows.append({"variable": name, "value": value, "source": source}) + return rows + + +@click.command(cls=Command, context_settings=CONTEXT_SETTINGS) +@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: + emit(f"{row['variable']}={row['value']} ({row['source']})") diff --git a/mobilerun/cli/main.py b/mobilerun/cli/main.py index 0838c164..98ad2e81 100644 --- a/mobilerun/cli/main.py +++ b/mobilerun/cli/main.py @@ -1,1392 +1,38 @@ -""" -Mobilerun CLI - Command line interface for controlling Android devices through LLM agents. -""" +"""Mobilerun CLI entry point.""" -import asyncio -import importlib.metadata -import logging -import math import os -import sys -import tomllib import warnings -from contextlib import nullcontext -from functools import wraps -from pathlib import Path +from importlib.metadata import version 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.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 load_llm -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, - 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.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 +from mobilerun.cli.options import Group, verbose_option +from mobilerun.cli.run import run, run_command -# Suppress all warnings -warnings.filterwarnings("ignore") -os.environ["TOKENIZERS_PARALLELISM"] = "false" -os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" +__all__ = ["cli", "run_command"] -console = Console() +@click.group(cls=Group) +@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" -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 +from mobilerun.cli.auth import auth_cli # 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 -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, - **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 - - # 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 - 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 = {} - 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 = MobileAgent( - goal=command, - llms=llm, - 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) - ) - - -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) - - -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)", - default=None, -) -@click.option( - "--model", - "-m", - help="LLM model name", - default=None, -) -@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, -): - """Run a command on your mobile device using natural language.""" - - if (provider is None) != (model is None): - raise click.UsageError("--provider and --model must be given together") - - 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, - ) - - # 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 - local_error = False - 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}[/]") - local_error = True - - # 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, - ) - - if local_error: - click.get_current_context().exit(1) - - -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) - except Exception as e: - raise click.ClickException(f"Error connecting to device: {e}") from e - if "connected to" in device: - console.print(f"[green]Successfully connected to {serial}[/]") - else: - raise click.ClickException(f"Failed to connect to {serial}: {device}") - - -@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: - raise click.ClickException(f"Error disconnecting from device: {e}") from 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: - raise click.ClickException(f"Could not find device {device}") - - 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 click.ClickException: - raise - except Exception as e: - if debug_mode: - import traceback - - traceback.print_exc() - raise click.ClickException(str(e)) from e - - -# 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, - ) +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) diff --git a/mobilerun/cli/run.py b/mobilerun/cli/run.py new file mode 100644 index 00000000..8b9f0f12 --- /dev/null +++ b/mobilerun/cli/run.py @@ -0,0 +1,443 @@ +""" +Mobilerun CLI - Command line interface for controlling Android devices through LLM agents. +""" + +import logging + +import click +from async_adbutils import adb +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.utils.android_device import ( + is_portal_http_url, +) +from mobilerun.agent.utils.errors import describe_error +from mobilerun.agent.utils.llm_picker import load_llm +from mobilerun.cli.event_handler import EventHandler +from mobilerun.cli.options import ( + CONTEXT_SETTINGS, + Command, + coro, + device_options, + emit, + json_option, + llm_url_option, + max_steps_option, + model_option, +) +from mobilerun.cli.providers import PROVIDERS, provider_option +from mobilerun.config_manager import ConfigLoader, MobileConfig +from mobilerun.log_handlers import CLILogHandler, configure_logging +from mobilerun.telemetry import print_telemetry_message + +console = Console(stderr=True) + + +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_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, + platform: str | None = None, + temperature: float | None = None, + llm_url: str | None = None, + target: str = "local", + api_url: str = "https://api.mobilerun.ai/v1", + json_output: bool = False, + **kwargs, +) -> bool: + """Run a command on your Android device using natural language. + + Returns: + bool: True if the task completed successfully, False otherwise. + """ + try: + config = ConfigLoader.load(config_path) + except Exception as exc: + if json_output: + emit({"success": False, "reason": str(exc), "steps": None}, json_output=True) + raise click.ClickException(f"Could not load config: {exc}") from exc + + # 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)") + 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 + + # Device overrides + if platform is not None: + config.device.platform = platform + elif ios: + config.device.platform = "ios" + 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. + from mobilerun.cli.selection import resolve_cloud_api_key, select_device + + if not config.device.control_backend: + selection = await select_device( + config.device.serial, + config_path, + tcp, + "ios" if ios else 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") + from mobilerun_core_local.driver.cloud import CloudDriver + + key = resolve_cloud_api_key() + if not key: + raise click.ClickException( + "No cloud API key found. Set MOBILERUN_CLOUD_API_KEY or run `mobilerun auth login`." + ) + injected_driver = CloudDriver( + device_id=selection.device, api_key=key, base_url=api_url + ) + await injected_driver.connect() + # 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 = {} + 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 + from mobilerun.cli.providers import llm_url_kwargs + + llm_kwargs.update(llm_url_kwargs(provider, llm_url)) + llm = load_llm(provider, model=model, **llm_kwargs, **kwargs) + else: + if llm_url is not None: + from mobilerun.cli.providers import llm_url_kwargs + + 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 + 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 = MobileAgent( + goal=command, + llms=llm, + 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() + + try: + handler = droid_agent.run() + + async for event in handler.stream_events(): + event_handler.handle(event) + result: ResultEvent = await handler + if json_output: + emit( + { + "success": result.success, + "reason": getattr(result, "reason", None), + "steps": getattr(result, "steps", None), + }, + json_output=True, + ) + return result.success + + except KeyboardInterrupt: + logger.info("âšī¸ Stopped by user") + raise + + 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()) + if json_output: + emit({"success": False, "reason": err_desc, "steps": None}, json_output=True) + return False + + except click.ClickException as exc: + if json_output and not isinstance(exc, click.UsageError): + emit({"success": False, "reason": exc.message, "steps": None}, json_output=True) + raise + 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()) + if json_output: + emit({"success": False, "reason": err_desc, "steps": None}, json_output=True) + return False + finally: + if not is_cloud: + await _cleanup_android_keyboard(config, getattr(droid_agent, "android_serial", None)) + + +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", err=True) + + +@click.command(cls=Command, context_settings=CONTEXT_SETTINGS) +@click.argument("command") +@device_options +@json_option +@provider_option +@model_option +@llm_url_option +@max_steps_option +@click.option("-T", "--temperature", type=float) +@click.option("-a", "--agent", help="External agent.") +@click.option("-r", "--reasoning/--no-reasoning", default=None) +@click.option("--vision/--no-vision", default=None) +@click.option("--vision-only", is_flag=True, default=None) +@click.option("--stream/--no-stream", default=None) +@click.option("--tracing/--no-tracing", default=None) +@click.option("--backend", type=click.Choice(["visual-remote"])) +@click.option("--backend-device", help="Device id for a visual remote backend.") +@click.option("--save-trajectory", type=click.Choice(["none", "step", "action"])) +@coro +async def run( + command, + 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, +): + """Run a task on a mobile device using natural language.""" + if (provider is None) != (model is None): + raise click.UsageError("--provider and --model must be given together") + success = await run_command( + command=command, + config_path=config_path, + device=device, + target=target, + ios=platform == "ios", + platform=platform, + api_url=api_url, + tcp=tcp, + debug=verbose, + json_output=json_output, + provider=PROVIDERS[provider].runtime if provider else None, + 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, + ) + if not success: + raise click.ClickException("Task failed") diff --git a/mobilerun/cli/selection.py b/mobilerun/cli/selection.py new file mode 100644 index 00000000..2161efa3 --- /dev/null +++ b/mobilerun/cli/selection.py @@ -0,0 +1,83 @@ +"""Resolve CLI config and device selection before invoking a backend.""" + +from dataclasses import dataclass + +import click +from mobilerun_core import Mobilerun +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.cli.options import DEFAULT_API_URL +from mobilerun.config_manager import ConfigLoader, MobileConfig + + +def resolve_cloud_api_key(): + import os + + from mobilerun.cli import deviceauth + + credentials = deviceauth.load_credentials() or {} + return ( + os.environ.get("MOBILERUN_CLOUD_API_KEY") + or credentials.get("api_key") + or credentials.get("access_token") + ) + + +def core_client(api_url=DEFAULT_API_URL): + return Mobilerun(bearer=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, +): + 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: + 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) diff --git a/mobilerun/cli/setup.py b/mobilerun/cli/setup.py new file mode 100644 index 00000000..9782e247 --- /dev/null +++ b/mobilerun/cli/setup.py @@ -0,0 +1,172 @@ +""" +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/macro/cli.py b/mobilerun/macro/cli.py index a5ab5776..7b9603ca 100644 --- a/mobilerun/macro/cli.py +++ b/mobilerun/macro/cli.py @@ -10,11 +10,18 @@ from rich.console import Console from rich.table import Table -from mobilerun.agent.utils.android_device import ( - AndroidDeviceSelectionError, - resolve_android_serial, -) from mobilerun.agent.utils.trajectory import Trajectory +from mobilerun.cli.options import ( + Group, + device_options, + emit, + json_option, + max_steps_option, + model_option, + verbose_option, +) +from mobilerun.cli.providers import PROVIDERS, provider_option +from mobilerun.cli.selection import select_device from mobilerun.config_manager.path_resolver import PathResolver from mobilerun.macro.replay import MacroPlayer @@ -43,27 +50,25 @@ def configure_logging(debug: bool = False): return logger -@click.group() -def macro_cli(): +@click.group(cls=Group) +@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) +@device_options @click.option( - "--delay", "-t", help="Delay between actions (seconds)", default=1.0, type=float + "--delay", help="Delay between actions (seconds)", default=1.0, type=float ) @click.option( - "--start-from", "-s", help="Start from step number (1-based)", default=1, type=int + "--start-from", help="Start from step number (1-based)", default=1, type=int ) +@max_steps_option @click.option( - "--max-steps", "-m", help="Maximum steps to execute", default=None, type=int -) -@click.option("--debug", is_flag=True, help="Enable debug logging", default=False) -@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 +91,15 @@ 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.") +@provider_option +@model_option 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,9 +107,12 @@ 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") @@ -117,23 +120,10 @@ def replay( 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}") + if dry_run: return device + selection = await select_device(device, config_path, tcp, platform, target) + return selection.device asyncio.run( _replay_with_device( @@ -151,6 +141,8 @@ async def get_device(): config_path, provider, model, + {"config_path": config_path, "tcp": tcp, "platform": platform, + "target": target, "api_url": api_url, "verbose": verbose, "framework": True}, ) ) @@ -170,6 +162,7 @@ async def _replay_with_device( config_path: Optional[str], provider: Optional[str], model: Optional[str], + connection_options=None, ): device = await get_device() await _replay_async( @@ -186,6 +179,7 @@ async def _replay_with_device( config_path, provider, model, + connection_options=connection_options, ) @@ -203,6 +197,7 @@ async def _replay_async( config_path: Optional[str], provider: Optional[str], model: Optional[str], + connection_options=None, ): """Async function to handle macro replay.""" try: @@ -236,12 +231,10 @@ async def _replay_async( ) macro_data = player.load_macro_from_folder(str(resolved_path)) else: - logger.error(f"❌ Invalid path: {resolved_path}") - return + raise click.ClickException(f"Invalid path: {resolved_path}") if not macro_data: - logger.error("❌ Failed to load macro data") - return + raise click.ClickException("Failed to load macro data") # Show macro information description = macro_data.get("description", "No description") @@ -270,21 +263,30 @@ async def _replay_async( await _show_dry_run(macro_data, start_from, max_steps, logger) else: logger.info("â–ļī¸ Starting macro replay...") - success = await player.replay_macro( - macro_data, start_from_step=start_from, max_steps=max_steps - ) + from mobilerun.cli.device_commands import _teardown_android, connect_driver + + player.driver, _ = await connect_driver(device=device, **(connection_options or {})) + try: + success = await player.replay_macro( + macro_data, start_from_step=start_from, max_steps=max_steps + ) + finally: + await _teardown_android(player.driver) if success: logger.info("🎉 Macro replay completed successfully!") else: - logger.error("đŸ’Ĩ Macro replay completed with errors") + raise click.ClickException("Macro replay failed") + except click.ClickException: + raise except Exception as e: logger.error(f"đŸ’Ĩ Error: {e}") if logger.isEnabledFor(logging.DEBUG): import traceback logger.debug(traceback.format_exc()) + raise click.ClickException(str(e)) from e async def _show_dry_run( @@ -341,15 +343,16 @@ 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() @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): +@verbose_option +@json_option +def list(directory: str, verbose: bool, json_output: bool): """List available trajectory folders in a directory.""" - logger = configure_logging(debug) + logger = configure_logging(bool(verbose)) # Resolve directory (checks working dir, then package dir) resolved_dir = PathResolver.resolve(directory, must_exist=True) @@ -371,6 +374,10 @@ 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 @@ -390,7 +397,7 @@ def list(directory: str, debug: bool): ) # Still use console for table display as it's structured data - console.print(table) + emit(console_render(table)) logger.info( f"💡 Use 'mobilerun macro replay {resolved_dir}/' to replay a trajectory" ) @@ -401,6 +408,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__": From d5e7db43a75c024f0589042607bc595ffc292d8a Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 17:50:36 +0100 Subject: [PATCH 05/59] fix(env): remove legacy names --- mobilerun/agent/droid/droid_agent.py | 1 - mobilerun/agent/fast_agent/fast_agent.py | 1 - mobilerun/agent/manager/manager_agent.py | 1 - mobilerun/agent/utils/oauth/anthropic_oauth_llm.py | 1 - .../utils/oauth/gemini_oauth_code_assist_llm.py | 1 - mobilerun/agent/utils/oauth/openai_oauth_llm.py | 1 - mobilerun/config_manager/loader.py | 12 +----------- mobilerun/telemetry/phoenix.py | 4 ++-- mobilerun/telemetry/tracker.py | 6 +++--- 9 files changed, 6 insertions(+), 22 deletions(-) diff --git a/mobilerun/agent/droid/droid_agent.py b/mobilerun/agent/droid/droid_agent.py index daa7eb38..90a112ff 100644 --- a/mobilerun/agent/droid/droid_agent.py +++ b/mobilerun/agent/droid/droid_agent.py @@ -408,7 +408,6 @@ def __init__( self._stream_screenshots = ( os.environ.get("MOBILERUN_STREAM_SCREENSHOTS") - or os.environ.get("DROIDRUN_STREAM_SCREENSHOTS") or "" ).lower() in ("1", "true") diff --git a/mobilerun/agent/fast_agent/fast_agent.py b/mobilerun/agent/fast_agent/fast_agent.py index aa34d6bf..94752c19 100644 --- a/mobilerun/agent/fast_agent/fast_agent.py +++ b/mobilerun/agent/fast_agent/fast_agent.py @@ -161,7 +161,6 @@ def __init__( self.save_trajectory = save_trajectory self._stream_screenshots = ( os.environ.get("MOBILERUN_STREAM_SCREENSHOTS") - or os.environ.get("DROIDRUN_STREAM_SCREENSHOTS") or "" ).lower() in ("1", "true") self.shared_state = shared_state diff --git a/mobilerun/agent/manager/manager_agent.py b/mobilerun/agent/manager/manager_agent.py index 34755452..722d3103 100644 --- a/mobilerun/agent/manager/manager_agent.py +++ b/mobilerun/agent/manager/manager_agent.py @@ -108,7 +108,6 @@ def __init__( self.save_trajectory = save_trajectory self._stream_screenshots = ( os.environ.get("MOBILERUN_STREAM_SCREENSHOTS") - or os.environ.get("DROIDRUN_STREAM_SCREENSHOTS") or "" ).lower() in ("1", "true") self.shared_state = shared_state diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index 2655654e..a831ef19 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -531,7 +531,6 @@ 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 "" ).lower() in ("1", "true", "yes") if use_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..516a3161 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -605,7 +605,6 @@ 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 "" ).lower() in ("1", "true", "yes") if use_authcode: diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index b7d193b8..44f88c88 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -777,7 +777,6 @@ 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 "" ).lower() in ("1", "true", "yes") if use_device_code: diff --git a/mobilerun/config_manager/loader.py b/mobilerun/config_manager/loader.py index ea9c1ba2..1f7a7f90 100644 --- a/mobilerun/config_manager/loader.py +++ b/mobilerun/config_manager/loader.py @@ -47,17 +47,7 @@ def load(cls, config_path: Optional[str] = None) -> MobileConfig: 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, - ) + env_config = os.environ.get("MOBILERUN_CONFIG") if env_config and Path(env_config).exists(): return cls._load_user_config(Path(env_config)) 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 From e039259b663c3a353984a949386ce3fa66b3832a Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 17:52:22 +0100 Subject: [PATCH 06/59] test(cli): cover command contract --- tests/test_android_device_selection.py | 17 +- tests/test_android_portal_http_wiring.py | 78 +++--- tests/test_cli_contract.py | 334 +++++++++++++++++++++++ tests/test_cli_exit_codes.py | 75 ++--- tests/test_cloud_cli.py | 173 ++++-------- tests/test_core_local_runtime.py | 10 +- tests/test_driver_reexports.py | 4 +- tests/test_grok_cli.py | 17 +- tests/test_ios_local_portal_wiring.py | 84 ++---- tests/test_keyboard_cleanup.py | 2 +- tests/test_oauth_manual_env_var.py | 8 +- tests/test_phoenix_tracing.py | 2 +- tests/test_sdk_debug_logging.py | 2 +- tests/test_setup_portal_explicit_apk.py | 12 +- tests/test_telemetry.py | 11 +- tests/test_vision_only_run_cli.py | 38 ++- 16 files changed, 547 insertions(+), 320 deletions(-) create mode 100644 tests/test_cli_contract.py diff --git a/tests/test_android_device_selection.py b/tests/test_android_device_selection.py index a4f043b2..6566acef 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")) @@ -165,10 +165,10 @@ async def fake_setup(*args, **kwargs): def _device_command_config(monkeypatch): - from mobilerun.cli import device_commands + from mobilerun.cli import device_commands, selection monkeypatch.setattr( - device_commands.ConfigLoader, + selection.ConfigLoader, "load", lambda _path: SimpleNamespace( device=SimpleNamespace( @@ -200,7 +200,7 @@ def test_device_command_with_multiple_devices_raises_click_error( _set(adb_devices, ("emulator-5554", "device"), ("emulator-5556", "device")) with pytest.raises(click.ClickException) as exc: - asyncio.run(device_commands._create_driver(None, None, None, False)) + asyncio.run(device_commands.connect_driver(None, None, None, None)) assert "emulator-5554, emulator-5556" in exc.value.message assert serials == [] @@ -210,7 +210,7 @@ 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")) - asyncio.run(device_commands._create_driver(None, None, None, False)) + asyncio.run(device_commands.connect_driver(None, None, None, None)) assert serials == ["emulator-5554"] @@ -348,5 +348,6 @@ 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 + assert "adb server not running" 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..8b11cf4e 100644 --- a/tests/test_android_portal_http_wiring.py +++ b/tests/test_android_portal_http_wiring.py @@ -1,6 +1,5 @@ import asyncio -import click import pytest from mobilerun_core_local.driver.android import AndroidPortalHttpDriver @@ -98,65 +97,62 @@ def _config_file(tmp_path): def test_device_commands_route_url_to_portal_http(tmp_path, monkeypatch, no_adb): - from mobilerun.cli import device_commands + from mobilerun_core import Mobilerun - driver = FakePortalHttpDriver() - created = _record_factory(monkeypatch, device_commands, driver) - monkeypatch.setenv("MOBILERUN_DEVICE_TOKEN", "sekret") + from mobilerun.cli import device_commands - result, is_ios = run( - device_commands._create_driver( - device=PORTAL_URL, - config_path=_config_file(tmp_path), - tcp=None, - ios=False, - ) - ) + created = {} + def connect(self, device_id, **kwargs): + created.update(kwargs) + return object() - assert result is driver and driver.connected + monkeypatch.setattr(Mobilerun, "connect", connect) + monkeypatch.setenv("MOBILERUN_DEVICE_TOKEN", "sekret") + driver, is_ios = run(device_commands.connect_driver( + device=PORTAL_URL, config_path=_config_file(tmp_path), platform="android")) + assert isinstance(driver, device_commands.CoreDriver) assert is_ios is False - assert created == {"url": PORTAL_URL, "token": "sekret"} + 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_DEVICE_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 - - 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) - - result = CliRunner().invoke(cli_main.cli, ["ping", "--device", PORTAL_URL]) + from mobilerun.cli.main import cli + created = {} + def connect(self, device_id, **kwargs): + created.update(kwargs) + class Core: + def ui(self): + return {} + return Core() + + monkeypatch.setattr(Mobilerun, "connect", connect) + monkeypatch.setenv("MOBILERUN_DEVICE_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)) diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py new file mode 100644 index 00000000..51cd6297 --- /dev/null +++ b/tests/test_cli_contract.py @@ -0,0 +1,334 @@ +"""The public CLI is a single, deliberately breaking command contract.""" + +import importlib +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import click +import pytest +from click.testing import CliRunner + +from mobilerun.cli import auth, device_commands, device_lifecycle, deviceauth, selection +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"} + assert set(cli.commands["device"].commands) == { + "list", "connect", "disconnect", "setup", "ping", "tap", "swipe", "long-press", + "type", "press", "screenshot", "ui", "apps", "start", + } + assert set(cli.commands["auth"].commands) == {"login", "logout", "status"} + 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", + "-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("--")): + # The frozen command-specific contract explicitly assigns -a twice. + long = "--all" if path == ("device", "list") else "--agent" + assert (expected.get(short, long)) 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, envvar, flag, value, override, keyword): + callback = AsyncMock(return_value=True) + monkeypatch.setattr(run_module, "run_command", 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_DEVICE_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_DEVICE_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(selection, "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={}) + monkeypatch.setattr(mobilerun_core.Mobilerun, "list_devices", lambda *a, **kw: [core]) + driver = SimpleNamespace(screenshot=AsyncMock(return_value=b"png"), + get_apps=AsyncMock(return_value=[{"package": "example"}])) + monkeypatch.setattr(device_commands, "connect_driver", AsyncMock(return_value=(driver, False))) + provider = SimpleNamespace(get_state=AsyncMock(return_value=SimpleNamespace(formatted_text="tree", phone_state={}))) + monkeypatch.setattr(device_commands, "AndroidStateProvider", lambda *a, **kw: provider) + 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): + doctor = importlib.import_module("mobilerun.cli.doctor_command") + monkeypatch.setattr(doctor, "select_device", AsyncMock(side_effect=RuntimeError("missing device"))) + result = CliRunner().invoke(cli, ["doctor", "-j"]) + assert result.exit_code == 1 + assert json.loads(result.stdout)[0]["status"] == "fail" + + +@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(): + 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(monkeypatch): + import warnings + + import mobilerun.cli.main as main + + monkeypatch.delenv("TOKENIZERS_PARALLELISM", raising=False) + monkeypatch.delenv("GRPC_ENABLE_FORK_SUPPORT", raising=False) + original = list(warnings.filters) + importlib.reload(main) + import os + assert "TOKENIZERS_PARALLELISM" not in os.environ + assert "GRPC_ENABLE_FORK_SUPPORT" not in os.environ + assert warnings.filters == original + + +@pytest.mark.parametrize("success", [True, False]) +def test_run_json_is_built_from_result_event(monkeypatch, success): + class Handler: + async def stream_events(self): + print("progress on stderr") + if False: + yield None + + def __await__(self): + async def done(): + return SimpleNamespace(success=success, reason="observed result", steps=4) + return done().__await__() + + class Agent: + def __init__(self, **kwargs): + pass + def run(self): + return Handler() + + monkeypatch.setattr(run_module, "MobileAgent", Agent) + 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 + class Agent: + def __init__(self, **kwargs): + calls.append(kwargs) + def run(self): + 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__() + return Handler() + monkeypatch.setattr(cloud, "CloudDriver", Driver) + monkeypatch.setattr(run_module, "MobileAgent", Agent) + 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 json.loads(result.stdout)[0]["error"] == "session check failed" + 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(macro, "MacroPlayer", Player) + monkeypatch.setattr(macro, "select_device", AsyncMock(return_value=SimpleNamespace(device="serial"))) + monkeypatch.setattr(device_commands, "connect_driver", AsyncMock(return_value=(object(), False))) + result = CliRunner().invoke(cli, ["macro", "replay", str(path)]) + assert result.exit_code == 1 + assert "Macro replay failed" in result.output diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py index 3db4b8f3..13730336 100644 --- a/tests/test_cli_exit_codes.py +++ b/tests/test_cli_exit_codes.py @@ -1,15 +1,25 @@ +import importlib from unittest.mock import AsyncMock +import pytest from click.testing import CliRunner -from mobilerun.cli import main +from mobilerun.cli import device_lifecycle, main, selection +from mobilerun.config_manager import MobileConfig + +run_module = importlib.import_module("mobilerun.cli.run") + + +@pytest.fixture(autouse=True) +def config(monkeypatch): + monkeypatch.setattr(selection.ConfigLoader, "load", lambda _: MobileConfig()) def test_connect_fresh_success(monkeypatch): connect = AsyncMock(return_value="connected to 127.0.0.1:5555") - monkeypatch.setattr(main.adb, "connect", connect) + monkeypatch.setattr(device_lifecycle.adb, "connect", connect) - result = CliRunner().invoke(main.cli, ["connect", "127.0.0.1:5555"]) + 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 @@ -18,10 +28,10 @@ def test_connect_fresh_success(monkeypatch): def test_connect_already_connected(monkeypatch): monkeypatch.setattr( - main.adb, "connect", AsyncMock(return_value="already connected to device") + device_lifecycle.adb, "connect", AsyncMock(return_value="already connected to device") ) - result = CliRunner().invoke(main.cli, ["connect", "device"]) + result = CliRunner().invoke(main.cli, ["device", "connect", "device"]) assert result.exit_code == 0, result.output assert "Successfully connected to device" in result.output @@ -29,10 +39,10 @@ def test_connect_already_connected(monkeypatch): def test_connect_failure_string(monkeypatch): monkeypatch.setattr( - main.adb, "connect", AsyncMock(return_value="connection refused") + device_lifecycle.adb, "connect", AsyncMock(return_value="connection refused") ) - result = CliRunner().invoke(main.cli, ["connect", "device"]) + 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 @@ -40,77 +50,70 @@ def test_connect_failure_string(monkeypatch): def test_connect_exception(monkeypatch): monkeypatch.setattr( - main.adb, "connect", AsyncMock(side_effect=RuntimeError("connection broke")) + device_lifecycle.adb, "connect", AsyncMock(side_effect=RuntimeError("connection broke")) ) - result = CliRunner().invoke(main.cli, ["connect", "device"]) + result = CliRunner().invoke(main.cli, ["device", "connect", "device"]) assert result.exit_code == 1 - assert "Error: Error connecting to device: connection broke" in result.output + assert "Error: connection broke" in result.output def test_disconnect_exception(monkeypatch): disconnect = AsyncMock(side_effect=RuntimeError("connection broke")) - monkeypatch.setattr(main.adb, "disconnect", disconnect) + monkeypatch.setattr(device_lifecycle.adb, "disconnect", disconnect) - result = CliRunner().invoke(main.cli, ["disconnect", "device"]) + result = CliRunner().invoke(main.cli, ["device", "disconnect", "device"]) assert result.exit_code == 1 - assert "Error: Error disconnecting from device: connection broke" in result.output + 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(main.adb, "disconnect", AsyncMock(return_value=False)) + monkeypatch.setattr(device_lifecycle.adb, "disconnect", AsyncMock(return_value=False)) - result = CliRunner().invoke(main.cli, ["disconnect", "device"]) + 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(main.adb, "device", AsyncMock(return_value=None)) + monkeypatch.setattr(device_lifecycle.adb, "device", AsyncMock(return_value=None)) - result = CliRunner().invoke(main.cli, ["ping", "-d", "no-such-device"]) + result = CliRunner().invoke(main.cli, ["device", "ping", "-d", "no-such-device"]) assert result.exit_code == 1 assert "Error: Could not find device no-such-device" in result.output def test_ping_driver_error(monkeypatch): - monkeypatch.setattr(main.adb, "device", AsyncMock(return_value=object())) + monkeypatch.setattr(device_lifecycle.adb, "device", AsyncMock(return_value=object())) monkeypatch.setattr( - main, "ping_portal", AsyncMock(side_effect=RuntimeError("portal broke")) + device_lifecycle, "ping_portal", AsyncMock(side_effect=RuntimeError("portal broke")) ) - result = CliRunner().invoke(main.cli, ["ping", "-d", "device", "--debug"]) + result = CliRunner().invoke(main.cli, ["device", "ping", "-d", "device", "--verbose"]) assert result.exit_code == 1 assert "Error: portal broke" in result.output - assert "Traceback (most recent call last):" in result.output -def test_devices_local_error_still_lists_cloud(monkeypatch): - monkeypatch.setattr( - main.adb, "list", AsyncMock(side_effect=RuntimeError("adb broke")) - ) - monkeypatch.setattr(main, "resolve_cloud_api_key", lambda: "test-token") - cloud_listing = AsyncMock(return_value=0) - monkeypatch.setattr(main, "_list_cloud_devices", cloud_listing) - - result = CliRunner().invoke(main.cli, ["devices"]) +def test_device_list_core_error_is_failure(monkeypatch): + class FakeCore: + def list_devices(self, **kwargs): + raise RuntimeError("adb broke") + monkeypatch.setattr(device_lifecycle, "core_client", lambda *_: FakeCore()) + result = CliRunner().invoke(main.cli, ["device", "list"]) assert result.exit_code == 1 - assert "Error listing local devices: adb broke" in result.output - cloud_listing.assert_awaited_once_with( - None, silent_if_no_key=True, silent_errors=True - ) + assert "adb broke" in result.output def test_run_provider_without_model(monkeypatch): run_command = AsyncMock() - monkeypatch.setattr(main, "run_command", run_command) + monkeypatch.setattr(run_module, "run_command", run_command) result = CliRunner().invoke(main.cli, ["run", "x", "--provider", "OpenAI"]) @@ -121,7 +124,7 @@ def test_run_provider_without_model(monkeypatch): def test_run_model_without_provider(monkeypatch): run_command = AsyncMock() - monkeypatch.setattr(main, "run_command", run_command) + monkeypatch.setattr(run_module, "run_command", run_command) result = CliRunner().invoke(main.cli, ["run", "x", "--model", "example"]) diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 8defd737..b6faacfd 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -43,135 +43,68 @@ def test_resolve_cloud_api_key_reads_saved_credential(monkeypatch, tmp_path): 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 = [] +def test_cloud_uuid_requires_credential_without_falling_back_to_adb(monkeypatch): + import click + import pytest - class FakeCloudDriver: - def __init__(self, **kwargs): - cloud_calls.append(kwargs) - - async def connect(self): - pass - - 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) - ) - - 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(selection.ConfigLoader, "load", lambda _: MobileConfig()) 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", - ) - ), - ) - 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") - - -def test_devices_cloud_missing_key_exits_nonzero(monkeypatch): - monkeypatch.setattr("mobilerun.cli.main.resolve_cloud_api_key", lambda: None) - - result = CliRunner().invoke(cli, ["devices", "--cloud"]) - - assert result.exit_code != 0 - assert "MOBILERUN_CLOUD_API_KEY" in result.output - + with pytest.raises(click.ClickException, match="No cloud API key"): + asyncio.run(device_commands.connect_driver("123e4567-e89b-12d3-a456-426614174000")) -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() +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) - - 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() - - 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 "No cloud devices found" in result.output - - -def test_devices_plain_keeps_local_listing_when_cloud_errors(monkeypatch): - class FakeAdbDevice: - serial = "emulator-5554" - - class FakeDevices: - async def list(self): + calls = [] + class FakeCore: + def __init__(self, **kwargs): + calls.append(kwargs) + def connect(self, device_id, **kwargs): + calls.append({"device_id": device_id, **kwargs}) + return object() + + monkeypatch.setattr(selection.ConfigLoader, "load", lambda _: MobileConfig()) + monkeypatch.setattr(selection, "Mobilerun", FakeCore) + monkeypatch.setattr(selection, "resolve_cloud_api_key", lambda: "test-key") + monkeypatch.setattr(device_commands, "resolve_cloud_api_key", lambda: "test-key") + device_id = "123e4567-e89b-12d3-a456-426614174000" + driver, _ = asyncio.run(device_commands.connect_driver(device_id, api_url="https://example.test/v1")) + assert isinstance(driver, device_commands.CoreDriver) + 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 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, "resolve_cloud_api_key", lambda: "test-key") + monkeypatch.setattr(device_lifecycle, "core_client", lambda *_: 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 list_devices(self, **kwargs): + assert kwargs["scope"] == "local" + return [SimpleNamespace(device_id="serial", capabilities={})] - assert result.exit_code == 0, result.output - assert "emulator-5554" in result.output - assert "cloud broke" not in result.output + monkeypatch.setattr(device_lifecycle, "core_client", lambda *_: FakeCore()) + result = CliRunner().invoke(cli, ["device", "list", "-j"]) + assert result.exit_code == 0 + assert '"device_id": "serial"' in result.stdout diff --git a/tests/test_core_local_runtime.py b/tests/test_core_local_runtime.py index d269b320..9d08513b 100644 --- a/tests/test_core_local_runtime.py +++ b/tests/test_core_local_runtime.py @@ -1,7 +1,7 @@ import asyncio from types import SimpleNamespace -from mobilerun.cli import device_commands +from mobilerun.cli import device_commands, selection from mobilerun.config_manager.config_manager import DeviceConfig @@ -34,7 +34,7 @@ async def connect(self): pass monkeypatch.setattr( - device_commands.ConfigLoader, + selection.ConfigLoader, "load", lambda _path: SimpleNamespace( device=SimpleNamespace( @@ -49,7 +49,7 @@ async def connect(self): monkeypatch.setattr(device_commands, "AndroidDriver", FakeAndroidDriver) driver, is_ios = asyncio.run( - device_commands._create_driver(None, None, None, False) + device_commands.connect_driver(None, None, None, None) ) assert isinstance(driver, FakeAndroidDriver) @@ -77,7 +77,7 @@ async def fake_ensure_portal_ready(*args, **kwargs): setup_calls.append((args, kwargs)) monkeypatch.setattr( - device_commands.ConfigLoader, + selection.ConfigLoader, "load", lambda _path: SimpleNamespace( device=SimpleNamespace( @@ -94,6 +94,6 @@ async def fake_ensure_portal_ready(*args, **kwargs): device_commands, "ensure_portal_ready", fake_ensure_portal_ready ) - asyncio.run(device_commands._create_driver(None, None, None, False)) + asyncio.run(device_commands.connect_driver(None, None, None, None)) assert setup_calls == [] 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_grok_cli.py b/tests/test_grok_cli.py index 5f5f536f..728f2679 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,7 +70,7 @@ 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), ) @@ -78,7 +79,7 @@ def test_configure_xai_command_forwards_device_code_options( result = CliRunner().invoke( cli_main.cli, [ - "configure", + "auth", "login", "xai", "--credential-path", str(credential_path), @@ -108,7 +109,7 @@ 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), ) @@ -117,7 +118,7 @@ def test_configure_anthropic_forwards_timeout_and_browser_preference( result = CliRunner().invoke( cli_main.cli, [ - "configure", + "auth", "login", "anthropic", "--credential-path", str(credential_path), @@ -140,7 +141,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 +209,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 @@ -274,7 +275,7 @@ def choose_model( # type: ignore[no-untyped-def] ), ) monkeypatch.setattr( - cli_main, + cli_auth, "_run_grok_oauth_login", lambda **kwargs: login_calls.append(kwargs), ) diff --git a/tests/test_ios_local_portal_wiring.py b/tests/test_ios_local_portal_wiring.py index d1abc7d5..a5f72233 100644 --- a/tests/test_ios_local_portal_wiring.py +++ b/tests/test_ios_local_portal_wiring.py @@ -130,7 +130,7 @@ async def get_ui_tree(self): async def fake_create_driver(*args, **kwargs): return driver, True - monkeypatch.setattr(device_commands, "_create_driver", fake_create_driver) + monkeypatch.setattr(device_commands, "connect_driver", fake_create_driver) result = CliRunner().invoke(device_commands.device_cli, ["ui"]) @@ -138,81 +138,43 @@ async def fake_create_driver(*args, **kwargs): assert "Privacy & Security" in result.output -def test_device_commands_route_ios_through_factory(tmp_path, monkeypatch): +def test_device_commands_route_ios_through_facade(tmp_path, monkeypatch): + from mobilerun_core import Mobilerun + 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) + def connect(self, device_id, **kwargs): + created.update(kwargs) + return object() + monkeypatch.setattr(Mobilerun, "connect", connect) monkeypatch.setenv("MOBILERUN_DEVICE_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, - ) - ) - + driver, is_ios = run(device_commands.connect_driver( + device="http://127.0.0.1:8080", config_path=str(cfg), platform="ios")) assert is_ios is True - assert created == { - "url": "http://127.0.0.1:8080", - "token": "sekret", - "connected": True, - } + 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.cli import device_commands - - created = {} + from mobilerun_core import Mobilerun - class FakeDriver: - platform = "iOS" + from mobilerun.cli import device_commands, selection - async def connect(self): - pass - - async def fake_discover_ios_device(): + created = {} + 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 object() + monkeypatch.setattr(selection, "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, - ) - ) - + driver, is_ios = run(device_commands.connect_driver(config_path=str(cfg), platform="ios")) assert is_ios is True assert created["url"] == "http://127.0.0.1:8081" diff --git a/tests/test_keyboard_cleanup.py b/tests/test_keyboard_cleanup.py index dc21ac97..abd606b2 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, 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 5ec6398d..fc267e3e 100644 --- a/tests/test_sdk_debug_logging.py +++ b/tests/test_sdk_debug_logging.py @@ -164,7 +164,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_telemetry.py b/tests/test_telemetry.py index fa14e4cf..8878dd8b 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: @@ -337,8 +337,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): @@ -361,5 +361,4 @@ async def skip_keyboard_cleanup(config, serial=None) -> None: 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_vision_only_run_cli.py b/tests/test_vision_only_run_cli.py index 7bde818f..3225a1d1 100644 --- a/tests/test_vision_only_run_cli.py +++ b/tests/test_vision_only_run_cli.py @@ -14,28 +14,28 @@ def test_vision_only_forwards_normal_run_options(self): async_run_command = AsyncMock(return_value=True) runner = CliRunner() - with patch("mobilerun.cli.main.run_command", async_run_command): + with patch("mobilerun.cli.run.run_command", 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", ], ) @@ -59,8 +59,8 @@ def test_omitted_control_backend_preserves_default_backend_behavior(self): 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_command", 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 +75,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) @@ -102,12 +102,11 @@ def run(self): with ( patch( - "mobilerun.cli.main.ConfigLoader.load", + "mobilerun.cli.run.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.cli.run.MobileAgent", FakeAgent), + patch("mobilerun.cli.selection.discover_ios_device", AsyncMock()) as discover, ): success = asyncio.run( run_command( @@ -123,7 +122,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") @@ -157,10 +155,10 @@ def run(self): with ( patch( - "mobilerun.cli.main.ConfigLoader.load", + "mobilerun.cli.run.ConfigLoader.load", return_value=MobileConfig(), ), - patch("mobilerun.cli.main.MobileAgent", FakeAgent), + patch("mobilerun.cli.run.MobileAgent", FakeAgent), ): success = asyncio.run( run_command( @@ -212,9 +210,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.cli.run.ConfigLoader.load", return_value=config), + patch("mobilerun.cli.run.MobileAgent", FakeAgent), + patch("mobilerun.cli.run.adb.device", AsyncMock()) as adb_device, ): success = asyncio.run(run_command("Check Wi-Fi", debug=False)) From a6ad9777968be33e71fed866072c4239fbc572e6 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 17:52:32 +0100 Subject: [PATCH 07/59] docs(cli): update command examples --- Dockerfile | 2 +- README.md | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 6 deletions(-) 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..b154adb6 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ uv tool install "mobilerun[anthropic]" ```bash uv tool install mobilerun -mobilerun setup +mobilerun device setup mobilerun configure mobilerun run "Open settings and turn on dark mode" ``` @@ -106,7 +106,7 @@ Before starting, make sure you have [ADB](https://developer.android.com/studio/r ### 1. Install the Portal on your device ```bash -mobilerun setup +mobilerun device setup ``` This installs the Mobilerun Portal app, enables its accessibility service, and prepares the device for local control. @@ -114,7 +114,7 @@ This installs the Mobilerun Portal app, enables its accessibility service, and p ### 2. Verify the connection ```bash -mobilerun ping +mobilerun device ping ``` You should see confirmation that the Portal is installed and accessible. @@ -139,10 +139,74 @@ Useful run options: 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 +mobilerun run "Take a screenshot" --platform ios +mobilerun run "Open Settings" --max-steps 30 --verbose ``` +### CLI commands and environment + +```text +mobilerun run "TASK" +mobilerun device list|connect|disconnect|setup|ping +mobilerun device tap|swipe|long-press|type|press|screenshot|ui|apps|start +mobilerun auth login [SERVICE] +mobilerun auth logout [SERVICE] +mobilerun auth status +mobilerun configure +mobilerun macro replay|list +mobilerun doctor +mobilerun env +``` + +Use `-h` or `--help` on any command, and `-V` or `--version` for the version. +Read commands and `run` accept `-j`/`--json`; stdout contains one JSON document, +while progress and errors go to stderr. Failures exit 1, invalid usage exits 2, +and interruption exits 130. + +Device commands, `run`, `doctor`, and macro replay share `-d`/`--device`, +`-t`/`--target local|cloud`, `-P`/`--platform android|ios`, `-c`/`--config`, +`-v`/`--verbose`, `--api-url`, and `--tcp`/`--no-tcp`. An explicit device +selects its backend: an ADB serial, a Portal HTTP URL, or a cloud device id. +Without a device, local commands auto-select; cloud commands require an id. + +```bash +mobilerun auth login +mobilerun auth status -j +mobilerun device list -t cloud -j +mobilerun device ui -d "$DEVICE_ID" -j +mobilerun run "Report the current app" -d "$DEVICE_ID" -j +mobilerun auth login openai --no-browser +mobilerun configure -p openai -m gpt-6.1-sol +mobilerun run "Open settings" -p ollama -m llama3.2 -u http://localhost:11434 -s 20 +mobilerun macro replay trajectories/example -s 5 -n +mobilerun env -j +``` + +`auth login` and `auth logout` default to `mobilerun`; provider services are +`openai`, `anthropic`, `gemini`, and `xai`. The configure wizard and `run -p` +share these provider names: `openai`, `openai-oauth`, `anthropic`, +`anthropic-oauth`, `gemini`, `gemini-oauth`, `xai`, `ollama`, `openai-like`, +`minimax`, and `zai` (case-insensitive). `-u`/`--llm-url` sets the LLM endpoint; +`--api-url` sets the cloud device API endpoint. + +| Variable | Meaning | Default / flag | +| --- | --- | --- | +| `MOBILERUN_TARGET` | Where to discover devices | `local`; `-t`/`--target` | +| `MOBILERUN_DEVICE` | Serial, Portal URL, or cloud device id | Auto-select; `-d`/`--device` | +| `MOBILERUN_CLOUD_API_KEY` | Cloud credential | Saved `auth login` credential | +| `MOBILERUN_API_BASE_URL` | Cloud API URL | `https://api.mobilerun.ai/v1`; `--api-url` | +| `MOBILERUN_AUTH_URL` | Sign-in URL | `https://cloud.mobilerun.ai/api/auth`; `--auth-url` | +| `MOBILERUN_CONFIG` | Config file | User config directory; `-c`/`--config` | +| `MOBILERUN_DEVICE_TOKEN` | Portal HTTP token | Configured token, if any | +| `MOBILERUN_DEBUG` | Verbose logs | Off; `-v`/`--verbose` | +| `MOBILERUN_TELEMETRY_ENABLED` | Anonymous telemetry | `true`, subject to config policy | + +Flags override environment values, then config values and defaults. `mobilerun env` +shows resolved values and their sources; secret values appear only as `set` or +`not set`. Standard provider keys, `ANDROID_SERIAL`, `NO_COLOR`, `PHOENIX_URL`, +and `PHOENIX_PROJECT_NAME` remain supported. `MOBILERUN_STREAM_SCREENSHOTS` and +`MOBILERUN_OAUTH_MANUAL` control screenshot streaming and manual sign-in. + Read the full [framework documentation](https://docs.mobilerun.ai/framework/quickstart). ## âš™ī¸ Features From 1c6c6eb69ace45b51115bba33372c0f906ba3397 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 17:54:23 +0100 Subject: [PATCH 08/59] style(cli): trim trailing whitespace --- mobilerun/cli/device_commands.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index 6b5b2d3a..28b2b870 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -393,4 +393,3 @@ async def start(package, device, config_path, tcp, platform, target, api_url, ve click.echo(result, err=True) finally: await _teardown_android(driver) - From 93973abd5a0d15b450488185875ed0e03b85cf5d Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 18:22:27 +0100 Subject: [PATCH 09/59] fix(deps): validate locked installs --- uv.lock | 8 -------- 1 file changed, 8 deletions(-) diff --git a/uv.lock b/uv.lock index 37207b1f..b84a17c6 100644 --- a/uv.lock +++ b/uv.lock @@ -10,14 +10,6 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'win32'", ] -[options] -exclude-newer = "2026-11-04T16:27:30Z" - -[options.exclude-newer-package] -mobilerun-core = "2026-11-11T16:27:30Z" -mobilerun-core-local = "2026-11-11T16:27:30Z" -mobilerun-sdk = "2026-11-11T16:27:30Z" - [manifest] overrides = [{ name = "filelock", specifier = ">=4.2.1" }] From a994e147de530b09e6992c35217861ae7cef52b1 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 18:22:27 +0100 Subject: [PATCH 10/59] fix(cli): apply reviewed contract --- mobilerun/cli/auth.py | 106 +++----- mobilerun/cli/configure.py | 10 +- mobilerun/cli/configure_wizard.py | 14 +- mobilerun/cli/device_commands.py | 405 ++++++----------------------- mobilerun/cli/device_lifecycle.py | 153 ++++++----- mobilerun/cli/doctor.py | 64 ++--- mobilerun/cli/doctor_command.py | 41 +-- mobilerun/cli/env.py | 33 ++- mobilerun/cli/options.py | 20 +- mobilerun/cli/providers.py | 50 ++-- mobilerun/cli/run.py | 157 +++-------- mobilerun/cli/selection.py | 63 ++++- mobilerun/config_manager/loader.py | 28 +- mobilerun/macro/cli.py | 230 ++++++---------- 14 files changed, 509 insertions(+), 865 deletions(-) diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py index 3849e826..8a5bc5b8 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -1,9 +1,6 @@ -""" -Mobilerun CLI - Command line interface for controlling Android devices through LLM agents. -""" +"""Sign-in commands using the existing credential flows.""" import math -import os from pathlib import Path import click @@ -89,10 +86,6 @@ def _login_mobilerun(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: @@ -112,8 +105,6 @@ def _login_mobilerun(auth_url: str | None, client_id: str): 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 path = deviceauth.save_token(token, auth_url) email = None @@ -133,6 +124,38 @@ def _login_mobilerun(auth_url: str | None, client_id: str): "gemini": (GEMINI_OAUTH_CREDENTIAL_PATH, "gemini_oauth_code_assist"), "xai": (GROK_OAUTH_CREDENTIAL_PATH, "xai_oauth"), } +LOGIN_OPTIONS = { + "mobilerun": {"auth_url", "client_id"}, + "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" ) @@ -184,53 +207,10 @@ def login( token, ): """Log in to SERVICE (default mobilerun).""" - supported = { - "mobilerun": {"auth_url", "client_id"}, - "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"}, - } - for name in ( - "auth_url", - "client_id", - "credential_path", - "timeout", - "no_browser", - "callback_host", - "callback_port", - "callback_path", - "device_code", - "token", - "model", - ): + for name in set().union(*LOGIN_OPTIONS.values()): if ( ctx.get_parameter_source(name) == click.core.ParameterSource.COMMANDLINE - and name not in supported[service] + and name not in LOGIN_OPTIONS[service] ): raise click.UsageError(f"--{name.replace('_', '-')} is not supported for {service}") if service == "mobilerun": @@ -286,8 +266,7 @@ def logout(service, verbose): @auth_cli.command() @verbose_option @json_option -@auth_url_option -def status(verbose, json_output, auth_url): +def status(verbose, json_output): """Show saved sign-in status without exposing tokens.""" credentials = deviceauth.load_credentials() or {} token = credentials.get("access_token") @@ -303,7 +282,7 @@ def status(verbose, json_output, auth_url): if token: try: session = deviceauth.fetch_session( - auth_url or credentials.get("auth_url") or DEFAULT_AUTH_URL, token + credentials.get("auth_url") or DEFAULT_AUTH_URL, token ) if session is None: failed = True @@ -311,9 +290,6 @@ def status(verbose, json_output, auth_url): rows[0]["email"] = session.get("user", {}).get("email") except Exception: failed = True - if failed: - rows[0]["signed_in"] = False - rows[0]["error"] = "session check failed" for service, (path, variant) in SERVICES.items(): rows.append( { @@ -322,6 +298,10 @@ def status(verbose, json_output, auth_url): "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: @@ -329,7 +309,3 @@ def status(verbose, json_output, auth_url): label = "signed in" if row["signed_in"] else "not signed in" email = f" ({row['email']})" if row.get("email") else "" emit(f"{row['service']}: {label}{email} {row['credential_path']}") - if failed: - raise click.ClickException( - "Mobilerun session check failed; run `mobilerun auth login` again." - ) diff --git a/mobilerun/cli/configure.py b/mobilerun/cli/configure.py index da70c884..4fed057f 100644 --- a/mobilerun/cli/configure.py +++ b/mobilerun/cli/configure.py @@ -28,14 +28,14 @@ @verbose_option @config_option @click.option("-k", "--api-key", help="API key for the selected provider.") -@click.option("--auth-mode", help="Authentication mode for the provider.") +@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): """Configure provider, authentication and model with the wizard.""" spec = PROVIDERS.get(provider) - if provider and provider.endswith("-oauth"): - if auth_mode and auth_mode != "oauth": - raise click.UsageError("The selected provider requires --auth-mode oauth") - auth_mode = "oauth" + if spec: + if spec.family is None: + raise click.UsageError(f"Provider {provider} is supported by run only") + auth_mode = auth_mode or spec.auth_mode run_configure_wizard( Console(stderr=True), ConfigureWizardCallbacks( diff --git a/mobilerun/cli/configure_wizard.py b/mobilerun/cli/configure_wizard.py index 18ba1db7..b06005eb 100644 --- a/mobilerun/cli/configure_wizard.py +++ b/mobilerun/cli/configure_wizard.py @@ -730,7 +730,7 @@ def run_configure_wizard( base_url=base_url, ) if provider_configured: - _save_config(config, config_path) + ConfigLoader.save(config, path=config_path) _print_configure_summary( console, family_id=state.family_id or "", @@ -786,7 +786,7 @@ def run_configure_wizard( _configure_advanced_settings(console, config) elif action == "finish": - _save_config(config, config_path) + ConfigLoader.save(config, path=config_path) if provider_configured and state.family_id: _print_configure_summary( console, @@ -800,13 +800,3 @@ def run_configure_wizard( else: console.print("[green]Configuration saved.[/]") return - - -def _save_config(config, config_path): - if config_path is None: - return ConfigLoader.save(config) - from mobilerun.config_manager.migrations import CURRENT_VERSION - - data = config.to_dict() - data["_version"] = CURRENT_VERSION - return ConfigLoader._save_dict(data, Path(config_path)) diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index 28b2b870..6c66d3f7 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -1,264 +1,94 @@ -"""Direct device action CLI commands. - -Provides ``mobilerun device `` subcommands that bypass the LLM agent -and talk directly to the device driver. -""" +"""Synchronous device actions through mobilerun-core.""" import asyncio +import base64 import json -import logging -import os -import sys import tempfile +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, -) -from rich.console import Console - -from mobilerun.agent.utils.android_device import ( - create_android_portal_http_driver, - is_portal_http_url, -) -from mobilerun.agent.utils.portal_setup import portal_version_kwargs -from mobilerun.cli.options import Group, coro, device_options, emit, json_option, verbose_option -from mobilerun.cli.selection import core_client, resolve_cloud_api_key, select_device -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_CLOUD_API_KEY" - - - -class CoreDriver: - """Expose the core facade through the framework's async state-provider interface.""" - - def __init__(self, device, backend): - self.core = device - self.backend = backend - - async def screenshot(self): - import base64 - from pathlib import Path - - value = await asyncio.to_thread(self.core.screenshot) - if self.backend == "cloud": - path = Path(value) - try: - return path.read_bytes() - finally: - path.unlink(missing_ok=True) - return base64.b64decode(value) - - async def get_ui_tree(self, **kwargs): - return await asyncio.to_thread(self.core.ui) - - async def tap(self, x, y): - await asyncio.to_thread(self.core.tap, x, y, humanize=False) - - async def swipe(self, x1, y1, x2, y2, duration_ms=1000): - await asyncio.to_thread(self.core.swipe, x1, y1, x2, y2, ms=duration_ms, humanize=False) - - async def input_text(self, text, clear=False): - await asyncio.to_thread(self.core.type, text, clear=clear, humanize=False) - return True - - async def press_button(self, button): - await asyncio.to_thread(self.core.key, button) - - async def get_apps(self, include_system=False): - return await asyncio.to_thread(self.core.list_apps, include_system_apps=include_system) - - async def start_app(self, package): - await asyncio.to_thread(self.core.start_app, package) - return f"Started {package}" - - -async def connect_driver(device=None, config_path=None, tcp=None, platform=None, - target="local", api_url=DEFAULT_CLOUD_BASE_URL, verbose=None, framework=False): - _log_progress_to_stderr() - selection = await select_device(device, config_path, tcp, platform, target) - config = selection.config - config.device.serial = selection.device - config.device.platform = selection.platform - if selection.backend == "local-android-adb": - # The facade does not expose auto-setup, explicit TCP, or keyboard teardown. - return await _connect_local_driver(selection.device, config, tcp, False) - client = core_client(api_url) - if selection.backend == "cloud" and not resolve_cloud_api_key(): - raise click.ClickException( - "No cloud API key found. Set MOBILERUN_CLOUD_API_KEY or run `mobilerun auth login`." - ) - if framework: - # Preserve input_text's boolean failure result and the macro engine's - # async driver verbs (core.type discards the underlying boolean). - if selection.backend == "cloud": - from mobilerun_core_local.driver.cloud import CloudDriver - - driver = CloudDriver(device_id=selection.device, api_key=resolve_cloud_api_key(), base_url=api_url) - elif selection.backend == "local-ios-http": - driver = await create_ios_driver(selection.device, token=config.device.resolve_auth_token()) - else: - driver = create_android_portal_http_driver(selection.device, config.device.resolve_auth_token()) - await driver.connect() - return driver, selection.platform == "ios" - connected = client.connect( - selection.device, backend=selection.backend, platform=selection.platform, - url=selection.device if is_portal_http_url(selection.device) else None, - token=config.device.resolve_auth_token(), - ) - return CoreDriver(connected, selection.backend), selection.platform == "ios" - - -async def _connect_local_driver(device, config, tcp, ios): - - serial = device - 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, +from mobilerun.agent.utils.android_device import is_portal_http_url +from mobilerun.cli.options import Group, device_options, emit, json_option, verbose_option +from mobilerun.cli.selection import core_client, portal_token, select_device + + +@contextmanager +def device_driver(params=None, *, selection=None): + """Resolve Click's device options once and yield the connected core Device.""" + 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"), + )) + token = portal_token(selection) + client = core_client(params["api_url"], cloud=selection.backend == "cloud") + try: + 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: - ime = portal_ime_id(PORTAL_PACKAGE_NAME) - await driver.device.shell(f"ime disable {ime}") - except Exception: - pass + yield connected + finally: + close = getattr(connected, "close", None) + if close: + close() + finally: + close = getattr(client, "close", None) + if close: + close() -# --------------------------------------------------------------------------- -# Click group -# --------------------------------------------------------------------------- +def probe_device(params, *, selection=None): + with device_driver(params, selection=selection) as device: + device.ui() @click.group(cls=Group) @verbose_option def device_cli(verbose): """Direct device actions (screenshot, tap, swipe, etc.).""" - pass - - -# --------------------------------------------------------------------------- -# Commands -# --------------------------------------------------------------------------- @device_cli.command() @json_option @device_options -@coro -async def screenshot(device, config_path, tcp, platform, target, api_url, verbose, json_output): +def screenshot(json_output, **params): """Take a screenshot and print the saved file path to stdout.""" - driver, _ = await connect_driver( - device, config_path, tcp, platform, target, api_url, verbose - ) - try: - png_bytes = await driver.screenshot() - fd, path = tempfile.mkstemp(prefix="mobilerun_", suffix=".png") - try: - os.write(fd, png_bytes) - finally: - os.close(fd) + 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) - finally: - await _teardown_android(driver) @device_cli.command() @json_option @device_options -@coro -async def ui(device, config_path, tcp, platform, target, api_url, verbose, json_output): - """Print the UI accessibility tree with element bounds for targeting.""" - _log_progress_to_stderr() - driver, is_ios = await connect_driver( - device, config_path, tcp, platform, target, api_url, verbose - ) - try: - if isinstance(driver, CoreDriver): - state = await driver.get_ui_tree() - if json_output: - emit(state, json_output=True) - else: - tree = state.get("a11y_tree", state) - emit(tree if isinstance(tree, str) else json.dumps(state, indent=2)) - return - 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() - if json_output: - emit({"ui": state.formatted_text, "phone_state": state.phone_state}, json_output=True) - else: - emit(state.formatted_text) - if state.phone_state: - emit(f"\nPhone state: {state.phone_state}") - finally: - await _teardown_android(driver) +def ui(json_output, **params): + """Print the UI state returned by the device.""" + with device_driver(params) as device: + state = json.loads(device.ui_json()) + emit(state if json_output else json.dumps(state, indent=2), json_output=json_output) @device_cli.command() @click.argument("x", type=int) @click.argument("y", type=int) @device_options -@coro -async def tap(x, y, device, config_path, tcp, platform, target, api_url, verbose): +def tap(x, y, **params): """Tap at screen coordinates.""" - driver, _ = await connect_driver( - device, config_path, tcp, platform, target, api_url, verbose - ) - try: - await driver.tap(x, y) - click.echo(f"Tapped ({x}, {y})", err=True) - 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") @@ -266,130 +96,63 @@ async def tap(x, y, device, config_path, tcp, platform, target, api_url, verbose @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" -) +@click.option("--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, platform, target, api_url, verbose -): +def swipe_cmd(x1, y1, x2, y2, duration, **params): """Swipe from (x1, y1) to (x2, y2).""" - driver, _ = await connect_driver( - device, config_path, tcp, platform, target, api_url, verbose - ) - try: - await driver.swipe(x1, y1, x2, y2, duration_ms=int(duration * 1000)) - click.echo(f"Swiped ({x1}, {y1}) -> ({x2}, {y2})", err=True) - 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") @click.argument("x", type=int) @click.argument("y", type=int) @device_options -@coro -async def long_press(x, y, device, config_path, tcp, platform, target, api_url, verbose): +def long_press(x, y, **params): """Long press at screen coordinates.""" - if platform == "ios": - raise click.ClickException("long-press is not supported on iOS") - driver, is_ios = await connect_driver( - device, config_path, tcp, platform, target, api_url, verbose - ) - try: - if is_ios: - raise click.ClickException("long-press is not supported on iOS") - await driver.swipe(x, y, x, y, 1000) - click.echo(f"Long pressed ({x}, {y})", err=True) - finally: - await _teardown_android(driver) + 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") @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, platform, target, api_url, verbose -): - """Type text into the currently focused field. Use 'tap' first to focus.""" - driver, _ = await connect_driver( - device, config_path, tcp, platform, target, api_url, verbose, framework=True - ) - try: - success = await driver.input_text(text, clear) - if success: - click.echo(f"Typed: {text}", err=True) - 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() -@click.argument( - "button", type=click.Choice(["back", "home", "enter"], case_sensitive=False) -) +@click.argument("button", type=click.Choice(["back", "home", "enter"], case_sensitive=False)) @device_options -@coro -async def press(button, device, config_path, tcp, platform, target, api_url, verbose): +def press(button, **params): """Press a system button.""" - driver, _ = await connect_driver( - device, config_path, tcp, platform, target, api_url, verbose - ) - try: - await driver.press_button(button) - click.echo(f"Pressed {button}", err=True) - finally: - await _teardown_android(driver) + with device_driver(params) as device: + device.key(button) + click.echo(f"Pressed {button}", err=True) @device_cli.command() -@click.option("--system/--no-system", default=False, help="Include system apps") +@click.option("--system", is_flag=True, help="Include system apps.") @json_option @device_options -@coro -async def apps(system, device, config_path, tcp, platform, target, api_url, verbose, json_output): +def apps(system, json_output, **params): """List installed apps.""" - driver, _ = await connect_driver( - device, config_path, tcp, platform, target, api_url, verbose - ) - try: - app_list = await driver.get_apps(include_system=system) - if json_output: - emit(app_list, json_output=True) - return - for app in app_list: - label = app.get("label") or "" - package = ( - app.get("package") - or app.get("package_name") - or app.get("packageName") - or app.get("app_id") - or app.get("bundle_id") - or "" - ) - if label and label != package: - emit(f"{package} ({label})") - else: - emit(package) - finally: - await _teardown_android(driver) + with device_driver(params) as device: + result = device.list_apps(include_system_apps=system) + emit(result if json_output else json.dumps(result, indent=2), json_output=json_output) @device_cli.command() @click.argument("package") @device_options -@coro -async def start(package, device, config_path, tcp, platform, target, api_url, verbose): +def start(package, **params): """Launch an app by package name.""" - driver, _ = await connect_driver( - device, config_path, tcp, platform, target, api_url, verbose - ) - try: - result = await driver.start_app(package) - click.echo(result, err=True) - 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 index 5de16458..bf7526a4 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -1,121 +1,114 @@ -"""Device discovery, connection and Portal setup commands.""" - -import asyncio +"""Device discovery and Android lifecycle commands.""" import click from async_adbutils import adb -from mobilerun_core_local.driver.android.portal import ( - ping_portal, - ping_portal_content, - ping_portal_tcp, -) -from mobilerun.cli.device_commands import _teardown_android, connect_driver, device_cli -from mobilerun.cli.options import coro, device_options, emit, json_option -from mobilerun.cli.selection import core_client, resolve_cloud_api_key, select_device +from mobilerun.cli.device_commands import device_cli, probe_device +from mobilerun.cli.options import ( + api_url_option, + config_option, + coro, + device_option, + emit, + json_option, + platform_option, + target_option, + verbose_option, +) +from mobilerun.cli.selection import core_client, select_device from mobilerun.cli.setup import _setup_portal @device_cli.command("list") -@device_options +@target_option +@api_url_option +@verbose_option @json_option -@click.option("-a", "--all", "all_devices", is_flag=True, help="List local and cloud devices.") -@coro -async def list_devices( - device, config_path, tcp, platform, target, api_url, verbose, json_output, all_devices -): +@click.option("--all", "all_devices", is_flag=True, help="List local and cloud devices.") +def list_devices(target, api_url, verbose, json_output, all_devices): """List devices in the selected target.""" scope = "all" if all_devices else target - if scope in {"cloud", "all"} and not resolve_cloud_api_key(): - raise click.ClickException( - "No cloud API key found. Set MOBILERUN_CLOUD_API_KEY or run `mobilerun auth login`." - ) - devices = await asyncio.to_thread(core_client(api_url).list_devices, scope=scope) - rows = [{"device_id": item.device_id, "capabilities": item.capabilities} for item in devices] - if json_output: - emit(rows, json_output=True) - elif rows: - for row in rows: - emit(row["device_id"]) - else: - click.echo("No devices found.", err=True) + client = core_client(api_url, cloud=scope in {"cloud", "all"}) + devices = [] + try: + devices = client.list_devices(filters={"state": None}, scope=scope) + rows = [] + for device in devices: + # Core seeds this model from the list response, but has no public + # metadata accessor for name/state/type on Device yet. + metadata = getattr(device._conn, "_device_cache", None) + rows.append({ + "device_id": device.device_id, + "name": metadata.name if metadata else None, + "state": metadata.state if metadata else "connected", + "type": metadata.type if metadata else device.capabilities["backend"], + }) + if json_output: + emit(rows, json_output=True) + elif rows: + for row in rows: + emit(f"{row['device_id']} {row['name'] or '-'} {row['state']} {row['type']}") + else: + click.echo("No devices found.", err=True) + finally: + for resource in [*devices, client]: + close = getattr(resource, "close", None) + if close: + close() @device_cli.command() -@click.argument("serial", required=False) -@device_options +@click.argument("serial") +@verbose_option @coro -async def connect(serial, device, config_path, tcp, platform, target, api_url, verbose): +async def connect(serial, verbose): """Connect to an Android device over TCP/IP.""" - selection = device or serial - if not selection: - raise click.UsageError("Supply SERIAL or --device") - if target == "cloud" or platform == "ios": - raise click.UsageError("TCP connect requires a local Android device") - result = await adb.connect(selection) + result = await adb.connect(serial) if "connected to" not in result: - raise click.ClickException(f"Failed to connect to {selection}: {result}") - click.echo(f"Successfully connected to {selection}", err=True) + raise click.ClickException(f"Failed to connect to {serial}: {result}") + click.echo(f"Successfully connected to {serial}", err=True) @device_cli.command() -@click.argument("serial", required=False) -@device_options +@click.argument("serial") +@verbose_option @coro -async def disconnect(serial, device, config_path, tcp, platform, target, api_url, verbose): +async def disconnect(serial, verbose): """Disconnect an Android TCP/IP device.""" - selection = device or serial - if not selection: - raise click.UsageError("Supply SERIAL or --device") - if target == "cloud" or platform == "ios": - raise click.UsageError("TCP disconnect requires a local Android device") - success = await adb.disconnect(selection, raise_error=True) + success = await adb.disconnect(serial, raise_error=True) click.echo( - f"Successfully disconnected from {selection}" - if success - else f"Device {selection} was not connected", - err=True, + f"Successfully disconnected from {serial}" + if success else f"Device {serial} was not connected", err=True, ) @device_cli.command() -@device_options +@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, tcp, platform, target, api_url, verbose, apk, portal_version, latest -): +async def setup(device, config_path, verbose, apk, portal_version, latest): """Install and enable the Portal on a local Android device.""" 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, tcp, platform, target) + selection = await select_device(device, config_path) if selection.backend != "local-android-adb": - raise click.UsageError("Portal setup requires a local Android ADB device") + 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() -@device_options -@coro -async def ping(device, config_path, tcp, platform, target, api_url, verbose): - """Check whether a device and its Portal are reachable.""" - selection = await select_device(device, config_path, tcp, platform, target) - if selection.backend == "local-android-adb": - obj = await adb.device(selection.device) - if not obj: - raise click.ClickException(f"Could not find device {selection.device}") - await ping_portal(obj, bool(verbose)) - check = ping_portal_tcp if selection.config.device.use_tcp else ping_portal_content - await check(obj, bool(verbose)) - else: - driver, _ = await connect_driver( - device, config_path, tcp, platform, target, api_url, verbose - ) - try: - await driver.get_ui_tree() - finally: - await _teardown_android(driver) +@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/doctor.py b/mobilerun/cli/doctor.py index 9f42d131..d4ba2dfa 100644 --- a/mobilerun/cli/doctor.py +++ b/mobilerun/cli/doctor.py @@ -133,46 +133,20 @@ async def check_sdk_version(debug: bool) -> CheckResult: async def check_config(debug: bool, config_path: str | None = None) -> tuple[CheckResult, Any]: """Check config file exists and loads.""" - import os - from pathlib import Path - from mobilerun.config_manager import ConfigLoader from mobilerun.config_manager.loader import OutdatedConfigError - config_path = Path(config_path or os.environ.get("MOBILERUN_CONFIG") or ConfigLoader.get_user_config_path()) - - if not config_path.exists(): - # Will be created on first load - try: - config = ConfigLoader.load(str(config_path)) - 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(str(config_path)) - 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, @@ -678,6 +652,7 @@ async def run_doctor( device_serial: str | None = None, debug: bool = False, config_path: str | None = None, + *, target="local", platform=None, api_url=None, ) -> list[CheckResult]: """Run all doctor checks and auto-fix where possible.""" debug = debug or False @@ -700,11 +675,36 @@ async def run_doctor( 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) results.append(r) - if r.status == Status.FAIL: + if r_config.status == Status.FAIL or (r.status == Status.FAIL and not non_adb): + _print_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) + _print_result(result, debug) _print_summary(results) return results diff --git a/mobilerun/cli/doctor_command.py b/mobilerun/cli/doctor_command.py index 35f36845..08f84fe5 100644 --- a/mobilerun/cli/doctor_command.py +++ b/mobilerun/cli/doctor_command.py @@ -4,46 +4,23 @@ import click -from mobilerun.cli.device_commands import _teardown_android, connect_driver -from mobilerun.cli.doctor import CheckResult, Status, check_config, check_sdk_version, run_doctor +from mobilerun.cli.doctor import Status, run_doctor from mobilerun.cli.options import CONTEXT_SETTINGS, Command, coro, device_options, emit, json_option -from mobilerun.cli.selection import select_device @click.command(cls=Command, context_settings=CONTEXT_SETTINGS) @device_options @json_option @coro -async def doctor(device, config_path, tcp, platform, target, api_url, verbose, json_output): +async def doctor(device, config_path, platform, target, api_url, verbose, json_output): """Check system and device health; fail if any check fails.""" - try: - selection = await select_device(device, config_path, tcp, platform, target) - except Exception as exc: - results = [CheckResult("Device", Status.FAIL, str(exc))] - else: - if selection.backend == "local-android-adb": - results = await run_doctor(selection.device, bool(verbose), config_path) - else: - config_result, _ = await check_config(bool(verbose), config_path) - results = [await check_sdk_version(bool(verbose)), config_result] - try: - driver, _ = await connect_driver( - device, config_path, tcp, platform, target, api_url, verbose - ) - try: - await driver.get_ui_tree() - finally: - await _teardown_android(driver) - results.append(CheckResult("Device", Status.PASS, "UI tree is reachable")) - except Exception as exc: - results.append(CheckResult("Device", Status.FAIL, str(exc))) - if json_output: - emit( - [{**asdict(result), "status": result.status.value} for result in results], - json_output=True, - ) - else: + results = await run_doctor(device, bool(verbose), config_path, + target=target, platform=platform, api_url=api_url) + failed = any(result.status == Status.FAIL for result in results) + if not json_output: for result in results: emit(f"{result.name}: {result.status.value}: {result.message}") - if any(result.status == Status.FAIL for result in results): + if failed: raise click.ClickException("Health checks failed") + if json_output: + emit([{**asdict(result), "status": result.status.value} for result in results], json_output=True) diff --git a/mobilerun/cli/env.py b/mobilerun/cli/env.py index f39801ed..0ebe1178 100644 --- a/mobilerun/cli/env.py +++ b/mobilerun/cli/env.py @@ -1,7 +1,6 @@ """Inspect resolved CLI environment values and their sources.""" import os -from pathlib import Path import click @@ -19,22 +18,28 @@ from mobilerun.cli.selection import resolve_cloud_api_key from mobilerun.config_manager import ConfigLoader, MobileConfig from mobilerun.config_manager.env_keys import API_KEY_ENV_VARS, load_env_key_sources +from mobilerun.telemetry.tracker import is_telemetry_enabled def resolved_environment(config_path=None): - path = Path( - config_path or os.environ.get("MOBILERUN_CONFIG") or ConfigLoader.get_user_config_path() - ) + path = ConfigLoader.resolve_path(config_path) exists = path.exists() - config = ConfigLoader.load(str(path)) if exists or config_path or os.environ.get("MOBILERUN_CONFIG") else MobileConfig() + 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_key = resolve_cloud_api_key() credentials = deviceauth.load_credentials() or {} values = { "MOBILERUN_TARGET": ("local", "default", False), - "MOBILERUN_DEVICE": (config.device.serial, config_source, False), + "MOBILERUN_DEVICE": (config.device.serial, config_source if config.device.serial else "not set", False), "MOBILERUN_CLOUD_API_KEY": ( - resolve_cloud_api_key(), - "credential file" if resolve_cloud_api_key() else "default", + cloud_key, + "credential file" if cloud_key else "not set", True, ), "MOBILERUN_API_BASE_URL": (DEFAULT_API_URL, "default", False), @@ -43,8 +48,8 @@ def resolved_environment(config_path=None): "credential file" if credentials.get("auth_url") else "default", False, ), - "MOBILERUN_CONFIG": (str(path), "default", False), - "MOBILERUN_DEVICE_TOKEN": (config.device.auth_token, config_source, True), + "MOBILERUN_CONFIG": (str(path), path_source, False), + "MOBILERUN_DEVICE_TOKEN": (config.device.auth_token, config_source if config.device.auth_token else "not set", True), "MOBILERUN_DEBUG": (config.logging.debug, config_source, False), "MOBILERUN_TELEMETRY_ENABLED": (config.telemetry.enabled, config_source, False), "MOBILERUN_STREAM_SCREENSHOTS": (False, "default", False), @@ -62,8 +67,14 @@ def resolved_environment(config_path=None): values[name] = (None, "default", True) rows = [] for name, (value, source, secret) in values.items(): - if os.environ.get(name): + if os.environ.get(name) and not (name == "MOBILERUN_CONFIG" and path_source == "flag"): value, source = os.environ[name], "env" + if name == "MOBILERUN_TELEMETRY_ENABLED": + value = is_telemetry_enabled(config_enabled=config.telemetry.enabled) + if name == "MOBILERUN_DEBUG" and source == "env": + value = value.lower() in {"1", "true", "yes", "on"} + if secret and not value: + source = "not set" if secret: value = "set" if value else "not set" rows.append({"variable": name, "value": value, "source": source}) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index 5d8a49f4..ad14c28f 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -3,6 +3,7 @@ import asyncio import json import sys +import traceback from contextlib import redirect_stdout from functools import wraps @@ -16,6 +17,7 @@ class Command(click.Command): def invoke(self, ctx): ctx.ensure_object(dict) ctx.obj["stdout"] = sys.stdout + ctx.obj["emitted"] = False if "verbose" in ctx.params and ctx.params["verbose"] is None: parent = ctx.parent while parent: @@ -33,13 +35,22 @@ def invoke(self, ctx): try: # Libraries and OAuth flows may print progress directly. with redirect_stdout(sys.stderr): + from mobilerun.config_manager import ConfigLoader + + if ctx.command.name != "doctor": + ConfigLoader.resolve_path(ctx.params.get("config_path")) return super().invoke(ctx) except (KeyboardInterrupt, asyncio.CancelledError, click.Abort): ctx.exit(130) - except (click.ClickException, SystemExit): - raise except Exception as exc: - raise click.ClickException(str(exc) or type(exc).__name__) from exc + if not isinstance(exc, click.ClickException): + if ctx.params.get("verbose"): + traceback.print_exc(file=sys.stderr) + exc = click.ClickException(str(exc) or type(exc).__name__) + if (not isinstance(exc, click.UsageError) and ctx.params.get("json_output") + and not ctx.obj["emitted"]): + emit({"error": exc.message}, json_output=True) + raise exc finally: logger.handlers, level, logger.propagate = previous logger.setLevel(level) @@ -128,7 +139,6 @@ def device_options(function): platform_option, config_option, api_url_option, - tcp_option, verbose_option, ): function = option(function) @@ -148,3 +158,5 @@ def emit(value, *, json_output=False): ctx = click.get_current_context(silent=True) output = ctx.obj.get("stdout") if ctx and ctx.obj else sys.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 diff --git a/mobilerun/cli/providers.py b/mobilerun/cli/providers.py index 3802cc7e..37ccdc23 100644 --- a/mobilerun/cli/providers.py +++ b/mobilerun/cli/providers.py @@ -1,41 +1,53 @@ -"""Public provider spellings shared by run and the configuration wizard.""" +"""Public provider spellings shared by run and configuration.""" from dataclasses import dataclass import click +from mobilerun.agent.providers.registry import resolve_provider_variant + @dataclass(frozen=True) class Provider: - runtime: str - family: str + family: str | None auth_mode: str url_keyword: str + run_only: str | None = None + + @property + def runtime(self): + return resolve_provider_variant(self.family, self.auth_mode).id if self.family else self.run_only PROVIDERS = { - "openai": Provider("OpenAIResponses", "openai", "api_key", "api_base"), - "openai-oauth": Provider("openai_oauth", "openai", "oauth", "api_base"), - "anthropic": Provider("Anthropic", "anthropic", "api_key", "base_url"), - "anthropic-oauth": Provider("anthropic_oauth", "anthropic", "oauth", "api_base"), - "gemini": Provider("GoogleGenAI", "gemini", "api_key", "http_options"), - "gemini-oauth": Provider("gemini_oauth_code_assist", "gemini", "oauth", "code_assist_endpoint"), - "xai": Provider("XAI", "xai", "api_key", "api_base"), - "ollama": Provider("Ollama", "ollama", "none", "base_url"), - "openai-like": Provider("OpenAILike", "openai_like", "api_key", "api_base"), - "minimax": Provider("MiniMax", "minimax", "api_key", "api_base"), - "zai": Provider("ZAI", "zai", "api_key", "api_base"), + "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"), } provider_option = click.option( - "-p", - "--provider", - type=click.Choice(list(PROVIDERS), case_sensitive=False), - help="LLM provider.", + "-p", "--provider", type=click.Choice(list(PROVIDERS), case_sensitive=False), help="LLM provider.", ) def llm_url_kwargs(runtime, url): if url is None: return {} - spec = next(spec for spec in PROVIDERS.values() if spec.runtime == runtime) + 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} diff --git a/mobilerun/cli/run.py b/mobilerun/cli/run.py index 8b9f0f12..936d39a3 100644 --- a/mobilerun/cli/run.py +++ b/mobilerun/cli/run.py @@ -1,6 +1,4 @@ -""" -Mobilerun CLI - Command line interface for controlling Android devices through LLM agents. -""" +"""Run tasks with the framework agent and native async drivers.""" import logging @@ -12,14 +10,15 @@ from rich.text import Text from mobilerun import MobileAgent, ResultEvent +from mobilerun.agent.external import list_agents from mobilerun.agent.utils.android_device import ( is_portal_http_url, ) -from mobilerun.agent.utils.errors import describe_error from mobilerun.agent.utils.llm_picker import load_llm from mobilerun.cli.event_handler import EventHandler from mobilerun.cli.options import ( CONTEXT_SETTINGS, + DEFAULT_API_URL, Command, coro, device_options, @@ -28,8 +27,10 @@ llm_url_option, max_steps_option, model_option, + tcp_option, ) -from mobilerun.cli.providers import PROVIDERS, provider_option +from mobilerun.cli.providers import PROVIDERS, llm_url_kwargs, provider_option +from mobilerun.cli.selection import _native_driver, select_device from mobilerun.config_manager import ConfigLoader, MobileConfig from mobilerun.log_handlers import CLILogHandler, configure_logging from mobilerun.telemetry import print_telemetry_message @@ -56,7 +57,7 @@ def _setup_cli_logging(debug: bool) -> None: configure_logging(debug=debug, handler=handler) -async def run_command( +async def _run_task( command: str, config_path: str | None = None, device: str | None = None, @@ -64,13 +65,8 @@ async def run_command( 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, @@ -79,26 +75,14 @@ async def run_command( control_backend: str | None = None, device_id: str | None = None, save_trajectory: str | None = None, - ios: bool = False, platform: str | None = None, temperature: float | None = None, llm_url: str | None = None, target: str = "local", - api_url: str = "https://api.mobilerun.ai/v1", - json_output: bool = False, - **kwargs, -) -> bool: - """Run a command on your Android device using natural language. - - Returns: - bool: True if the task completed successfully, False otherwise. - """ - try: - config = ConfigLoader.load(config_path) - except Exception as exc: - if json_output: - emit({"success": False, "reason": str(exc), "steps": None}, json_output=True) - raise click.ClickException(f"Could not load config: {exc}") from exc + api_url: str = DEFAULT_API_URL, +) -> tuple[bool, str | None, int | None]: + """Execute a task and return the result-event fields.""" + config = ConfigLoader.load(config_path) # Print cloud link in a box if config.logging.rich_text: @@ -143,15 +127,6 @@ async def run_command( 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 @@ -165,8 +140,6 @@ async def run_command( # Device overrides if platform is not None: config.device.platform = platform - elif ios: - config.device.platform = "ios" if device is not None: config.device.serial = device if tcp is not None: @@ -191,14 +164,12 @@ async def run_command( config.tracing.enabled = tracing # The framework keeps its native async drivers. Core decides the backend. - from mobilerun.cli.selection import resolve_cloud_api_key, select_device - if not config.device.control_backend: selection = await select_device( config.device.serial, config_path, tcp, - "ios" if ios else config.device.platform, + config.device.platform, target, discover=False, config=config, @@ -209,17 +180,7 @@ async def run_command( is_cloud = True if tcp: raise click.UsageError("--tcp is only supported for local Android devices") - from mobilerun_core_local.driver.cloud import CloudDriver - - key = resolve_cloud_api_key() - if not key: - raise click.ClickException( - "No cloud API key found. Set MOBILERUN_CLOUD_API_KEY or run `mobilerun auth login`." - ) - injected_driver = CloudDriver( - device_id=selection.device, api_key=key, base_url=api_url - ) - await injected_driver.connect() + 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 @@ -251,26 +212,14 @@ async def run_command( llm_kwargs = {} 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 - from mobilerun.cli.providers import llm_url_kwargs - llm_kwargs.update(llm_url_kwargs(provider, llm_url)) - llm = load_llm(provider, model=model, **llm_kwargs, **kwargs) + llm = load_llm(provider, model=model, **llm_kwargs) else: if llm_url is not None: - from mobilerun.cli.providers import llm_url_kwargs - 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 - 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 = MobileAgent( goal=command, @@ -290,57 +239,26 @@ async def run_command( event_handler = EventHandler() - try: - handler = droid_agent.run() - - async for event in handler.stream_events(): - event_handler.handle(event) - result: ResultEvent = await handler - if json_output: - emit( - { - "success": result.success, - "reason": getattr(result, "reason", None), - "steps": getattr(result, "steps", None), - }, - json_output=True, - ) - return result.success - - except KeyboardInterrupt: - logger.info("âšī¸ Stopped by user") - raise - - 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()) - if json_output: - emit({"success": False, "reason": err_desc, "steps": None}, json_output=True) - return False - - except click.ClickException as exc: - if json_output and not isinstance(exc, click.UsageError): - emit({"success": False, "reason": exc.message, "steps": None}, json_output=True) - raise - 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()) - if json_output: - emit({"success": False, "reason": err_desc, "steps": None}, json_output=True) - return False + 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.""" + try: + success, _, _ = await _run_task(*args, **kwargs) + return success + except Exception: + return False + + 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() @@ -367,15 +285,16 @@ async def _cleanup_android_keyboard(config: MobileConfig, serial: str | None = N @click.command(cls=Command, context_settings=CONTEXT_SETTINGS) -@click.argument("command") +@click.argument("task") @device_options +@tcp_option @json_option @provider_option @model_option @llm_url_option @max_steps_option @click.option("-T", "--temperature", type=float) -@click.option("-a", "--agent", help="External agent.") +@click.option("-a", "--agent", type=click.Choice(list_agents()), help="External agent.") @click.option("-r", "--reasoning/--no-reasoning", default=None) @click.option("--vision/--no-vision", default=None) @click.option("--vision-only", is_flag=True, default=None) @@ -386,7 +305,7 @@ async def _cleanup_android_keyboard(config: MobileConfig, serial: str | None = N @click.option("--save-trajectory", type=click.Choice(["none", "step", "action"])) @coro async def run( - command, + task, config_path, device, target, @@ -413,17 +332,15 @@ async def run( """Run a task on a mobile device using natural language.""" if (provider is None) != (model is None): raise click.UsageError("--provider and --model must be given together") - success = await run_command( - command=command, + success, reason, steps = await _run_task( + command=task, config_path=config_path, device=device, target=target, - ios=platform == "ios", platform=platform, api_url=api_url, tcp=tcp, debug=verbose, - json_output=json_output, provider=PROVIDERS[provider].runtime if provider else None, model=model, llm_url=llm_url, @@ -439,5 +356,7 @@ async def run( device_id=backend_device, save_trajectory=save_trajectory, ) + if json_output: + emit({"success": success, "reason": reason, "steps": steps}, json_output=True) if not success: - raise click.ClickException("Task failed") + raise click.ClickException(reason or "Task failed") diff --git a/mobilerun/cli/selection.py b/mobilerun/cli/selection.py index 2161efa3..f2fffff5 100644 --- a/mobilerun/cli/selection.py +++ b/mobilerun/cli/selection.py @@ -3,7 +3,7 @@ from dataclasses import dataclass import click -from mobilerun_core import Mobilerun +import mobilerun_core from mobilerun_core.detect import is_uuid, resolve_backend from mobilerun_core_local.driver.ios import discover_ios_device, validate_ios_portal_url @@ -29,8 +29,10 @@ def resolve_cloud_api_key(): ) -def core_client(api_url=DEFAULT_API_URL): - return Mobilerun(bearer=resolve_cloud_api_key(), base_url=api_url) +def core_client(api_url=DEFAULT_API_URL, *, cloud=False): + return mobilerun_core.Mobilerun( + bearer=require_cloud_api_key() if cloud else resolve_cloud_api_key(), base_url=api_url + ) @dataclass @@ -81,3 +83,58 @@ async def select_device( 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_CLOUD_API_KEY or run `mobilerun auth login`." + ) + return key + + +def portal_token(selection): + token = selection.config.device.resolve_auth_token() + if selection.backend == "local-android-http" and not token: + raise click.ClickException("Portal URL requires MOBILERUN_DEVICE_TOKEN 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 + + 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/config_manager/loader.py b/mobilerun/config_manager/loader.py index 1f7a7f90..c9493a5e 100644 --- a/mobilerun/config_manager/loader.py +++ b/mobilerun/config_manager/loader.py @@ -44,20 +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") - 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.""" @@ -87,11 +87,11 @@ 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 7b9603ca..c2a599e1 100644 --- a/mobilerun/macro/cli.py +++ b/mobilerun/macro/cli.py @@ -2,7 +2,6 @@ Command-line interface for Mobilerun macro replay. """ -import asyncio import logging from typing import Optional @@ -13,20 +12,20 @@ from mobilerun.agent.utils.trajectory import Trajectory from mobilerun.cli.options import ( Group, + coro, device_options, emit, json_option, max_steps_option, model_option, + tcp_option, verbose_option, ) from mobilerun.cli.providers import PROVIDERS, provider_option -from mobilerun.cli.selection import select_device +from mobilerun.cli.selection import _native_driver, select_device, teardown_native_driver from mobilerun.config_manager.path_resolver import PathResolver from mobilerun.macro.replay import MacroPlayer -console = Console() - def configure_logging(debug: bool = False): """Configure logging for the macro CLI.""" @@ -60,6 +59,7 @@ def macro_cli(verbose): @macro_cli.command() @click.argument("path", type=click.Path(exists=True)) @device_options +@tcp_option @click.option( "--delay", help="Delay between actions (seconds)", default=1.0, type=float ) @@ -93,7 +93,8 @@ def macro_cli(verbose): ) @provider_option @model_option -def replay( +@coro +async def replay( path: str, device: Optional[str], delay: float, @@ -119,67 +120,11 @@ def replay( # Convert start_from from 1-based to 0-based start_from_zero = max(0, start_from - 1) - async def get_device(): - if dry_run: - return device - selection = await select_device(device, config_path, tcp, platform, target) - return selection.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, - {"config_path": config_path, "tcp": tcp, "platform": platform, - "target": target, "api_url": api_url, "verbose": verbose, "framework": True}, - ) - ) - - -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], - connection_options=None, -): - device = await get_device() + selection = None if dry_run else await select_device(device, config_path, tcp, platform, target) await _replay_async( - path, - device, - delay, - start_from, - max_steps, - dry_run, - logger, - on_mismatch, - state_timeout, - state_threshold, - config_path, - provider, - model, - connection_options=connection_options, + path, selection.device if selection else device, delay, start_from_zero, + max_steps, dry_run, logger, on_mismatch, state_timeout, state_threshold, + config_path, provider, model, selection=selection, api_url=api_url, ) @@ -197,96 +142,85 @@ async def _replay_async( config_path: Optional[str], provider: Optional[str], model: Optional[str], - connection_options=None, + 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: - raise click.ClickException(f"Invalid path: {resolved_path}") + # 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: + raise click.ClickException(f"Invalid path: {resolved_path}") - if not macro_data: - raise click.ClickException("Failed to load macro data") + 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}") - 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) + 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.info("â–ļī¸ Starting macro replay...") - from mobilerun.cli.device_commands import _teardown_android, connect_driver - - player.driver, _ = await connect_driver(device=device, **(connection_options or {})) - try: - success = await player.replay_macro( - macro_data, start_from_step=start_from, max_steps=max_steps - ) - finally: - await _teardown_android(player.driver) - - if success: - logger.info("🎉 Macro replay completed successfully!") - else: - raise click.ClickException("Macro replay failed") - - except click.ClickException: - raise - except Exception as e: - logger.error(f"đŸ’Ĩ Error: {e}") - if logger.isEnabledFor(logging.DEBUG): - import traceback + raise click.ClickException("Macro replay failed") - logger.debug(traceback.format_exc()) - raise click.ClickException(str(e)) from e async def _show_dry_run( From 285357fc2077eb9ce2afae8d3879009943138b43 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 18:22:36 +0100 Subject: [PATCH 11/59] test(cli): cover review regressions --- tests/test_android_device_selection.py | 60 ++-- tests/test_android_portal_http_wiring.py | 7 +- tests/test_cli_contract.py | 359 +++++++++++++++++++---- tests/test_cli_exit_codes.py | 16 +- tests/test_cloud_cli.py | 28 +- tests/test_configure_prompts.py | 2 +- tests/test_core_local_runtime.py | 23 +- tests/test_gemini_oauth_llm.py | 2 +- tests/test_grok_cli.py | 6 +- tests/test_ios_local_portal_wiring.py | 46 +-- tests/test_keyboard_cleanup.py | 2 +- tests/test_state_fetch_retry_policy.py | 35 +-- tests/test_vision_only_run_cli.py | 18 +- 13 files changed, 403 insertions(+), 201 deletions(-) diff --git a/tests/test_android_device_selection.py b/tests/test_android_device_selection.py index 6566acef..cca64d94 100644 --- a/tests/test_android_device_selection.py +++ b/tests/test_android_device_selection.py @@ -164,54 +164,28 @@ async def fake_setup(*args, **kwargs): assert device_calls == ["emulator-5556"] -def _device_command_config(monkeypatch): - from mobilerun.cli import device_commands, selection - - monkeypatch.setattr( - selection.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) +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 _set(adb_devices, ("emulator-5554", "device"), ("emulator-5556", "device")) - - with pytest.raises(click.ClickException) as exc: - asyncio.run(device_commands.connect_driver(None, None, None, None)) - - assert "emulator-5554, emulator-5556" in exc.value.message - assert serials == [] + with pytest.raises(click.ClickException, match="emulator-5554, emulator-5556"): + with device_driver({"api_url": DEFAULT_API_URL}): + pytest.fail("must not connect") 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")) - - asyncio.run(device_commands.connect_driver(None, None, None, None)) + import mobilerun_core + from mobilerun.cli.device_commands import device_driver + from mobilerun.cli.options import DEFAULT_API_URL + _set(adb_devices, ("emulator-5556", "unauthorized"), ("emulator-5554", "device")) + serials = [] + def connect(self, serial, **kwargs): + serials.append(serial) + return object() + monkeypatch.setattr(mobilerun_core.Mobilerun, "connect", connect) + with device_driver({"api_url": DEFAULT_API_URL}): + pass assert serials == ["emulator-5554"] diff --git a/tests/test_android_portal_http_wiring.py b/tests/test_android_portal_http_wiring.py index 8b11cf4e..e6d18743 100644 --- a/tests/test_android_portal_http_wiring.py +++ b/tests/test_android_portal_http_wiring.py @@ -108,10 +108,9 @@ def connect(self, device_id, **kwargs): monkeypatch.setattr(Mobilerun, "connect", connect) monkeypatch.setenv("MOBILERUN_DEVICE_TOKEN", "sekret") - driver, is_ios = run(device_commands.connect_driver( - device=PORTAL_URL, config_path=_config_file(tmp_path), platform="android")) - assert isinstance(driver, device_commands.CoreDriver) - assert is_ios is False + 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" diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index 51cd6297..a3317e22 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -2,15 +2,17 @@ import importlib import json +import subprocess +import sys from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import click import pytest from click.testing import CliRunner -from mobilerun.cli import auth, device_commands, device_lifecycle, deviceauth, selection +from mobilerun.cli import auth, device_lifecycle, deviceauth, selection from mobilerun.cli.main import cli from mobilerun.cli.providers import PROVIDERS, llm_url_kwargs from mobilerun.config_manager import ConfigLoader, MobileConfig, credential_paths @@ -56,14 +58,12 @@ def test_tree_and_short_options(): 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", - "-r": "--reasoning", "-n": "--dry-run", "-k": "--api-key", "-V": "--version"} + "-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("--")): - # The frozen command-specific contract explicitly assigns -a twice. - long = "--all" if path == ("device", "list") else "--agent" - assert (expected.get(short, long)) in param.opts, (path, param.opts) + assert expected[short] in param.opts, (path, param.opts) @pytest.mark.parametrize("args", [ @@ -89,9 +89,13 @@ def test_old_commands_and_flags_are_usage_errors(args): ("MOBILERUN_CONFIG", "--config", "a.yaml", "b.yaml", "config_path"), ("MOBILERUN_DEBUG", "--verbose", "false", None, "debug"), ]) -def test_env_binding_and_flag_precedence(monkeypatch, envvar, flag, value, override, keyword): - callback = AsyncMock(return_value=True) - monkeypatch.setattr(run_module, "run_command", callback) +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 @@ -156,13 +160,14 @@ def test_cloud_target_without_device_does_not_touch_adb(monkeypatch): def test_read_commands_emit_one_document(monkeypatch, args): import mobilerun_core - core = SimpleNamespace(device_id="id", capabilities={}) + core = SimpleNamespace(device_id="id", capabilities={"backend": "local-android-adb"}, _conn=object()) monkeypatch.setattr(mobilerun_core.Mobilerun, "list_devices", lambda *a, **kw: [core]) - driver = SimpleNamespace(screenshot=AsyncMock(return_value=b"png"), - get_apps=AsyncMock(return_value=[{"package": "example"}])) - monkeypatch.setattr(device_commands, "connect_driver", AsyncMock(return_value=(driver, False))) - provider = SimpleNamespace(get_state=AsyncMock(return_value=SimpleNamespace(formatted_text="tree", phone_state={}))) - monkeypatch.setattr(device_commands, "AndroidStateProvider", lambda *a, **kw: provider) + 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) @@ -179,10 +184,11 @@ def test_macro_list_json(tmp_path): def test_doctor_failure_json(monkeypatch): doctor = importlib.import_module("mobilerun.cli.doctor_command") - monkeypatch.setattr(doctor, "select_device", AsyncMock(side_effect=RuntimeError("missing device"))) + from mobilerun.cli.doctor import CheckResult, Status + monkeypatch.setattr(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)[0]["status"] == "fail" + assert json.loads(result.stdout) == {"error": "Health checks failed"} @pytest.mark.parametrize("service,option", [("mobilerun", "--model"), ("xai", "--callback-host"), @@ -197,7 +203,8 @@ def test_provider_table_matches_wizard_and_loaders(): from mobilerun.agent.providers.registry import resolve_provider_variant for provider in PROVIDERS.values(): - assert resolve_provider_variant(provider.family, provider.auth_mode).id == provider.runtime + 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"}} @@ -209,41 +216,44 @@ def test_interrupt_exits_130(monkeypatch): assert result.exit_code == 130 -def test_import_has_no_cli_global_side_effects(monkeypatch): - import warnings +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 - import mobilerun.cli.main as main - monkeypatch.delenv("TOKENIZERS_PARALLELISM", raising=False) - monkeypatch.delenv("GRPC_ENABLE_FORK_SUPPORT", raising=False) - original = list(warnings.filters) - importlib.reload(main) - import os - assert "TOKENIZERS_PARALLELISM" not in os.environ - assert "GRPC_ENABLE_FORK_SUPPORT" not in os.environ - assert warnings.filters == original - - -@pytest.mark.parametrize("success", [True, False]) -def test_run_json_is_built_from_result_event(monkeypatch, success): +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 SimpleNamespace(success=success, reason="observed result", steps=4) + return result return done().__await__() - class Agent: def __init__(self, **kwargs): - pass + if calls is not None: + calls.append(kwargs) def run(self): return Handler() + return Agent - monkeypatch.setattr(run_module, "MobileAgent", Agent) + +@pytest.mark.parametrize("success", [True, False]) +def test_run_json_is_built_from_result_event(monkeypatch, success): + monkeypatch.setattr(run_module, "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} @@ -260,21 +270,8 @@ def __init__(self, **kwargs): calls.append(kwargs) async def connect(self): pass - class Agent: - def __init__(self, **kwargs): - calls.append(kwargs) - def run(self): - 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__() - return Handler() monkeypatch.setattr(cloud, "CloudDriver", Driver) - monkeypatch.setattr(run_module, "MobileAgent", Agent) + monkeypatch.setattr(run_module, "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"]) @@ -309,7 +306,7 @@ def fail(*args): monkeypatch.setattr(deviceauth, "fetch_session", fail) result = CliRunner().invoke(cli, ["auth", "status", "-j"]) assert result.exit_code == 1 - assert json.loads(result.stdout)[0]["error"] == "session check failed" + assert "session check failed" in json.loads(result.stdout)["error"] assert "fake-secret" not in result.output @@ -327,8 +324,260 @@ async def replay_macro(self, *args, **kwargs): return False monkeypatch.setattr(macro, "MacroPlayer", Player) - monkeypatch.setattr(macro, "select_device", AsyncMock(return_value=SimpleNamespace(device="serial"))) - monkeypatch.setattr(device_commands, "connect_driver", AsyncMock(return_value=(object(), False))) + 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}", _conn=SimpleNamespace( + _device_cache=SimpleNamespace(name=f"device-{state}", state=state, type="android"))) + for state in ("ready", "stopped", "provisioning")] + list_call = Mock(return_value=rows) + 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 list_call.call_args.kwargs == {"filters": {"state": None}, "scope": scope} + data = json.loads(result.stdout) + 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} device-{state} {state} android" 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(run_module.ConfigLoader, "load", lambda _: config) + monkeypatch.setattr(run_module, "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(run_module, "load_llm", load) + monkeypatch.setattr(run_module, "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 "run only" 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_DEVICE_TOKEN", raising=False) + monkeypatch.setenv("MOBILERUN_ANDROID_PORTAL_TOKEN", "unrelated-token") + result = CliRunner().invoke(cli, ["device", "tap", "1", "2", "-d", "http://127.0.0.1:9"]) + assert result.exit_code == 1 + assert "MOBILERUN_DEVICE_TOKEN" in result.stderr + assert "MOBILERUN_ANDROID_PORTAL_TOKEN" not 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_DEVICE_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(configure, "run_configure_wizard", wizard) + 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): + macro = importlib.import_module("mobilerun.macro.cli") + 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(macro, "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", "-c"): + assert runner.invoke(cli, ["device", "list", flag, "x"]).exit_code == 2 diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py index 13730336..d052312b 100644 --- a/tests/test_cli_exit_codes.py +++ b/tests/test_cli_exit_codes.py @@ -80,24 +80,18 @@ def test_disconnect_not_connected_is_success(monkeypatch): def test_ping_unknown_device(monkeypatch): - monkeypatch.setattr(device_lifecycle.adb, "device", AsyncMock(return_value=None)) - + 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: Could not find device no-such-device" in result.output + assert "Error: unknown device" in result.output def test_ping_driver_error(monkeypatch): - monkeypatch.setattr(device_lifecycle.adb, "device", AsyncMock(return_value=object())) - monkeypatch.setattr( - device_lifecycle, "ping_portal", AsyncMock(side_effect=RuntimeError("portal broke")) - ) - + 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): @@ -105,7 +99,7 @@ class FakeCore: def list_devices(self, **kwargs): raise RuntimeError("adb broke") - monkeypatch.setattr(device_lifecycle, "core_client", lambda *_: FakeCore()) + 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 diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index b6faacfd..341ab783 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -1,9 +1,8 @@ -import asyncio from types import SimpleNamespace from click.testing import CliRunner -from mobilerun.cli import device_commands +from mobilerun.cli import device_commands, selection from mobilerun.cli.main import cli @@ -17,7 +16,7 @@ def test_resolve_cloud_api_key_prefers_cloud_env(monkeypatch, tmp_path): monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", "env-token") monkeypatch.setenv("MOBILERUN_API_KEY", "legacy-token") - assert device_commands.resolve_cloud_api_key() == "env-token" + assert selection.resolve_cloud_api_key() == "env-token" def test_resolve_cloud_api_key_ignores_legacy_env(monkeypatch, tmp_path): @@ -27,7 +26,7 @@ def test_resolve_cloud_api_key_ignores_legacy_env(monkeypatch, tmp_path): monkeypatch.delenv("MOBILERUN_CLOUD_API_KEY", raising=False) monkeypatch.setenv("MOBILERUN_API_KEY", "legacy-token") - assert device_commands.resolve_cloud_api_key() is None + assert selection.resolve_cloud_api_key() is None def test_resolve_cloud_api_key_reads_saved_credential(monkeypatch, tmp_path): @@ -40,7 +39,7 @@ 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" + assert selection.resolve_cloud_api_key() == "saved-token" def test_cloud_uuid_requires_credential_without_falling_back_to_adb(monkeypatch): @@ -51,9 +50,10 @@ def test_cloud_uuid_requires_credential_without_falling_back_to_adb(monkeypatch) from mobilerun.config_manager import MobileConfig monkeypatch.setattr(selection.ConfigLoader, "load", lambda _: MobileConfig()) - monkeypatch.setattr(device_commands, "resolve_cloud_api_key", lambda: None) + monkeypatch.setattr(selection, "resolve_cloud_api_key", lambda: None) with pytest.raises(click.ClickException, match="No cloud API key"): - asyncio.run(device_commands.connect_driver("123e4567-e89b-12d3-a456-426614174000")) + with device_commands.device_driver({"device": "123e4567-e89b-12d3-a456-426614174000", "api_url": "https://example.test/v1"}): + pass def test_cloud_facade_receives_url_key_and_id(monkeypatch): @@ -69,12 +69,11 @@ def connect(self, device_id, **kwargs): return object() monkeypatch.setattr(selection.ConfigLoader, "load", lambda _: MobileConfig()) - monkeypatch.setattr(selection, "Mobilerun", FakeCore) + monkeypatch.setattr(selection.mobilerun_core, "Mobilerun", FakeCore) monkeypatch.setattr(selection, "resolve_cloud_api_key", lambda: "test-key") - monkeypatch.setattr(device_commands, "resolve_cloud_api_key", lambda: "test-key") device_id = "123e4567-e89b-12d3-a456-426614174000" - driver, _ = asyncio.run(device_commands.connect_driver(device_id, api_url="https://example.test/v1")) - assert isinstance(driver, device_commands.CoreDriver) + 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" @@ -88,8 +87,7 @@ def list_devices(self, **kwargs): assert kwargs["scope"] in {"cloud", "all"} raise RuntimeError("cloud broke") - monkeypatch.setattr(device_lifecycle, "resolve_cloud_api_key", lambda: "test-key") - monkeypatch.setattr(device_lifecycle, "core_client", lambda *_: FakeCore()) + 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 @@ -102,9 +100,9 @@ def test_local_listing_uses_local_scope(monkeypatch): class FakeCore: def list_devices(self, **kwargs): assert kwargs["scope"] == "local" - return [SimpleNamespace(device_id="serial", capabilities={})] + return [SimpleNamespace(device_id="serial", capabilities={"backend": "local-android-adb"}, _conn=object())] - monkeypatch.setattr(device_lifecycle, "core_client", lambda *_: FakeCore()) + 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 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 9d08513b..c9bb30e8 100644 --- a/tests/test_core_local_runtime.py +++ b/tests/test_core_local_runtime.py @@ -1,7 +1,7 @@ import asyncio from types import SimpleNamespace -from mobilerun.cli import device_commands, selection +from mobilerun.cli import selection from mobilerun.config_manager.config_manager import DeviceConfig @@ -46,14 +46,15 @@ async def connect(self): ) ), ) - monkeypatch.setattr(device_commands, "AndroidDriver", FakeAndroidDriver) + from mobilerun_core_local.driver import android + monkeypatch.setattr(android, "AndroidDriver", FakeAndroidDriver) - driver, is_ios = asyncio.run( - device_commands.connect_driver(None, None, None, None) - ) + 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", @@ -89,11 +90,15 @@ async def fake_ensure_portal_ready(*args, **kwargs): ) ), ) - monkeypatch.setattr(device_commands, "AndroidDriver", FakeAndroidDriver) + from mobilerun_core_local.driver import android + monkeypatch.setattr(android, "AndroidDriver", FakeAndroidDriver) monkeypatch.setattr( - device_commands, "ensure_portal_ready", fake_ensure_portal_ready + android.portal, "ensure_portal_ready", fake_ensure_portal_ready ) - asyncio.run(device_commands.connect_driver(None, None, None, None)) + async def connect(): + selected = await selection.select_device() + return await selection._native_driver(selected, selected.config, "https://example.test/v1") + asyncio.run(connect()) assert setup_calls == [] diff --git a/tests/test_gemini_oauth_llm.py b/tests/test_gemini_oauth_llm.py index cc23c086..c03b0479 100644 --- a/tests/test_gemini_oauth_llm.py +++ b/tests/test_gemini_oauth_llm.py @@ -422,7 +422,7 @@ 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 728f2679..8f321f2c 100644 --- a/tests/test_grok_cli.py +++ b/tests/test_grok_cli.py @@ -242,7 +242,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] @@ -307,7 +307,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, @@ -353,7 +353,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 a5f72233..e4ba7bc2 100644 --- a/tests/test_ios_local_portal_wiring.py +++ b/tests/test_ios_local_portal_wiring.py @@ -108,34 +108,20 @@ 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.""" - from click.testing import CliRunner - from mobilerun_core_local.driver.ios import IOSPortalHttpDriver - - from mobilerun.cli import device_commands - - class FakePortalHttpDriver(IOSPortalHttpDriver): - def __init__(self): # skip the httpx-client base __init__ - pass - - async def get_ui_tree(self): - return LOCAL_PORTAL_STATE +def test_device_commands_ui_returns_core_state(monkeypatch): + import json - driver = FakePortalHttpDriver() - - async def fake_create_driver(*args, **kwargs): - return driver, True - - monkeypatch.setattr(device_commands, "connect_driver", fake_create_driver) - - result = CliRunner().invoke(device_commands.device_cli, ["ui"]) + from click.testing import CliRunner + from mobilerun_core import Mobilerun + from mobilerun.cli.main import cli + class Device: + 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): @@ -151,9 +137,9 @@ def connect(self, device_id, **kwargs): monkeypatch.setenv("MOBILERUN_DEVICE_TOKEN", "sekret") cfg = tmp_path / "config.yaml" cfg.write_text("_version: 7\ndevice:\n platform: ios\n") - driver, is_ios = run(device_commands.connect_driver( - device="http://127.0.0.1:8080", config_path=str(cfg), platform="ios")) - assert is_ios is 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" @@ -174,8 +160,8 @@ def connect(self, device_id, **kwargs): 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.connect_driver(config_path=str(cfg), platform="ios")) - 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 abd606b2..51c50bc6 100644 --- a/tests/test_keyboard_cleanup.py +++ b/tests/test_keyboard_cleanup.py @@ -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_state_fetch_retry_policy.py b/tests/test_state_fetch_retry_policy.py index adcf976d..ecf9be8b 100644 --- a/tests/test_state_fetch_retry_policy.py +++ b/tests/test_state_fetch_retry_policy.py @@ -214,22 +214,19 @@ async def run(): assert cancelled == [True] -def test_device_ui_shows_info_progress_on_stderr(capsys): - from mobilerun.cli import device_commands - - 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 +def test_device_ui_shows_info_progress_on_stderr(monkeypatch): + from click.testing import CliRunner + from mobilerun_core import Mobilerun + + from mobilerun.cli.main import cli + + class Device: + 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_vision_only_run_cli.py b/tests/test_vision_only_run_cli.py index 3225a1d1..3bfbf916 100644 --- a/tests/test_vision_only_run_cli.py +++ b/tests/test_vision_only_run_cli.py @@ -11,10 +11,10 @@ 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.run.run_command", async_run_command): + with patch("mobilerun.cli.run._run_task", async_run_command): result = runner.invoke( cli, [ @@ -44,7 +44,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,11 +55,11 @@ 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.run.run_command", async_run_command), + 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"]) @@ -89,7 +89,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__() @@ -111,7 +111,7 @@ def run(self): success = asyncio.run( run_command( "Check Wi-Fi", - ios=True, + platform="ios", vision_only=True, control_backend="visual-remote", device="http://localhost:8090", @@ -142,7 +142,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__() @@ -188,7 +188,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__() From f892c38d2d497d0dd8aa6a7fac6fffaa8f4a1323 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 18:22:37 +0100 Subject: [PATCH 12/59] docs(cli): correct sign-in hints --- README.md | 2 +- mobilerun/agent/utils/llm_picker.py | 2 +- mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py | 2 +- mobilerun/agent/utils/oauth/grok_oauth_llm.py | 4 ++-- mobilerun/agent/utils/oauth/openai_oauth_llm.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b154adb6..7a683841 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ and interruption exits 130. Device commands, `run`, `doctor`, and macro replay share `-d`/`--device`, `-t`/`--target local|cloud`, `-P`/`--platform android|ios`, `-c`/`--config`, -`-v`/`--verbose`, `--api-url`, and `--tcp`/`--no-tcp`. An explicit device +`-v`/`--verbose`, `--api-url`. `run` and macro replay also accept `--tcp`/`--no-tcp`. An explicit device selects its backend: an ADB serial, a Portal HTTP URL, or a cloud device id. Without a device, local commands auto-select; cloud commands require an id. 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/gemini_oauth_code_assist_llm.py b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py index 516a3161..1514c590 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__( 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 44f88c88..8dd4339b 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" From bfaad9df80c204b99e8cdd700c7291b48c42adc8 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 18:40:24 +0100 Subject: [PATCH 13/59] feat(cli): add qr pairing --- README.md | 3 + mobilerun/cli/auth.py | 13 +- mobilerun/cli/device_lifecycle.py | 3 + mobilerun/cli/device_pair.py | 140 ++++++++++++++++ mobilerun/cli/qr.py | 13 ++ pyproject.toml | 1 + tests/test_cli_contract.py | 6 +- tests/test_cli_qr.py | 265 ++++++++++++++++++++++++++++++ uv.lock | 11 ++ 9 files changed, 451 insertions(+), 4 deletions(-) create mode 100644 mobilerun/cli/device_pair.py create mode 100644 mobilerun/cli/qr.py create mode 100644 tests/test_cli_qr.py diff --git a/README.md b/README.md index 7a683841..136ee390 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,9 @@ mobilerun doctor mobilerun env ``` +`mobilerun auth login` shows a sign-in QR code in the terminal; `--no-qr` suppresses it. +`mobilerun device pair` pairs and connects an Android device by scanning its QR code under Developer options → Wireless debugging → "Pair device with QR code" on the same Wi-Fi. + Use `-h` or `--help` on any command, and `-V` or `--version` for the version. Read commands and `run` accept `-j`/`--json`; stdout contains one JSON document, while progress and errors go to stderr. Failures exit 1, invalid usage exits 2, diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py index 8a5bc5b8..90fe9aab 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -23,6 +23,7 @@ model_option, verbose_option, ) +from mobilerun.cli.qr import print_qr from mobilerun.config_manager.auth_profile_store import AuthProfileStore from mobilerun.config_manager.credential_paths import ( ANTHROPIC_OAUTH_CREDENTIAL_PATH, @@ -82,7 +83,7 @@ def _run_grok_oauth_login(credential_path: str, model: str | None = None, **kwar DEFAULT_DEVICE_CLIENT_ID = "mobilerun-cli" -def _login_mobilerun(auth_url: str | None, client_id: str): +def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool = False): """Authenticate via OAuth 2.0 device authorization and save the session token.""" import webbrowser @@ -93,6 +94,8 @@ def _login_mobilerun(auth_url: str | None, client_id: str): console.print(f"Visit: [bold]{code.verification_uri}[/]") console.print(f"Code: [bold cyan]{code.user_code}[/]") + if code.verification_uri_complete and not no_qr: + print_qr(code.verification_uri_complete) if code.verification_uri_complete: console.print(f"[dim](Opening {code.verification_uri_complete} ...)[/]") try: @@ -125,7 +128,7 @@ def _login_mobilerun(auth_url: str | None, client_id: str): "xai": (GROK_OAUTH_CREDENTIAL_PATH, "xai_oauth"), } LOGIN_OPTIONS = { - "mobilerun": {"auth_url", "client_id"}, + "mobilerun": {"auth_url", "client_id", "no_qr"}, "openai": { "credential_path", "timeout", @@ -184,6 +187,7 @@ def auth_cli(verbose): help="Login timeout in seconds (default 300).", ) @click.option("--no-browser", is_flag=True, help="Do not open a 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.") @click.option("--callback-port", type=click.IntRange(0, 65535), help="OAuth callback port.") @click.option("--callback-path", help="OAuth callback path.") @@ -200,6 +204,7 @@ def login( credential_path, timeout, no_browser, + no_qr, callback_host, callback_port, callback_path, @@ -214,7 +219,9 @@ def login( ): raise click.UsageError(f"--{name.replace('_', '-')} is not supported for {service}") if service == "mobilerun": - _login_mobilerun(auth_url or DEFAULT_AUTH_URL, client_id or DEFAULT_DEVICE_CLIENT_ID) + _login_mobilerun( + auth_url or DEFAULT_AUTH_URL, client_id or DEFAULT_DEVICE_CLIENT_ID, no_qr + ) return kwargs = { "credential_path": credential_path or str(SERVICES[service][0]), diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index bf7526a4..187a9c54 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -4,6 +4,7 @@ from async_adbutils import adb from mobilerun.cli.device_commands import device_cli, probe_device +from mobilerun.cli.device_pair import pair from mobilerun.cli.options import ( api_url_option, config_option, @@ -18,6 +19,8 @@ from mobilerun.cli.selection import core_client, select_device from mobilerun.cli.setup import _setup_portal +device_cli.add_command(pair) + @device_cli.command("list") @target_option diff --git a/mobilerun/cli/device_pair.py b/mobilerun/cli/device_pair.py new file mode 100644 index 00000000..8fd88317 --- /dev/null +++ b/mobilerun/cli/device_pair.py @@ -0,0 +1,140 @@ +"""Pair and connect a local Android device through adb wireless debugging.""" + +import secrets +import shutil +import string +import subprocess +import sys +import time +from typing import NamedTuple + +import click + +from mobilerun.cli.options import CONTEXT_SETTINGS, Command, emit, verbose_option +from mobilerun.cli.qr import print_qr + + +class MdnsService(NamedTuple): + name: str + service_type: str + address: str + + @property + def ip(self) -> str: + return self.address.rsplit(":", 1)[0] + + +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(".") + if service_type not in {"_adb-tls-pairing._tcp", "_adb-tls-connect._tcp"}: + continue + host, separator, port = address.rpartition(":") + if host and separator and port.isdigit() and 0 < int(port) < 65536: + services.append(MdnsService(name, service_type, address)) + return services + + +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(*args: str, deadline: float, password: str = "", 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 password: + result.stdout = result.stdout.replace(password, "[redacted]") + result.stderr = result.stderr.replace(password, "[redacted]") + if result.returncode: + raise click.ClickException( + result.stderr.strip() or result.stdout.strip() or "adb command failed." + ) + return result + + +def _wait_for_service( + service_type: str, deadline: float, password: str, *, name: str | None = None, + ip: str | None = None, +) -> MdnsService: + while True: + result = _adb("mdns", "services", deadline=deadline, password=password) + for service in parse_mdns_services(result.stdout): + if (service.service_type == service_type + and (name is None or service.name == name) + and (ip is None or service.ip == ip)): + return service + remaining = deadline - time.monotonic() + if remaining <= 0: + raise click.ClickException("Timed out waiting for wireless debugging.") + time.sleep(min(1, remaining)) + + +@click.command(cls=Command, context_settings=CONTEXT_SETTINGS) +@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.") + if not shutil.which("adb"): + raise click.ClickException("adb is missing; install Android SDK Platform-Tools.") + deadline = time.monotonic() + timeout + 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.UsageError("This command needs a terminal.") + pairing = _wait_for_service( + "_adb-tls-pairing._tcp", deadline, password, name=name + ) + result = _adb("pair", pairing.address, input=password + "\n", + deadline=deadline, password=password) + if "successfully paired to" not in result.stdout.lower(): + raise click.ClickException( + result.stderr.strip() or result.stdout.strip() or "adb pairing failed." + ) + connection = _wait_for_service( + "_adb-tls-connect._tcp", deadline, password, ip=pairing.ip + ) + result = _adb("connect", connection.address, deadline=deadline, password=password) + if not any(line.lower().startswith(("connected to ", "already connected to ")) + for line in result.stdout.splitlines()): + raise click.ClickException( + result.stderr.strip() or result.stdout.strip() or "adb connection failed." + ) + emit(connection.address) + click.echo( + f"Paired and connected. Run `mobilerun device setup -d {connection.address}`.", + err=True, + ) diff --git a/mobilerun/cli/qr.py b/mobilerun/cli/qr.py new file mode 100644 index 00000000..a2140a44 --- /dev/null +++ b/mobilerun/cli/qr.py @@ -0,0 +1,13 @@ +"""Shared terminal QR rendering.""" + +import sys + +import segno + + +def print_qr(data: str) -> bool: + """Print a compact QR with its quiet zone to terminal stderr.""" + if not sys.stderr.isatty(): + return False + segno.make_qr(data).terminal(out=sys.stderr, compact=True) + return True diff --git a/pyproject.toml b/pyproject.toml index a15e0c0c..7f45af35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index a3317e22..bc4a58e3 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -52,9 +52,13 @@ def test_tree_and_short_options(): assert set(cli.commands) == {"run", "device", "auth", "configure", "macro", "doctor", "env"} assert set(cli.commands["device"].commands) == { "list", "connect", "disconnect", "setup", "ping", "tap", "swipe", "long-press", - "type", "press", "screenshot", "ui", "apps", "start", + "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", diff --git a/tests/test_cli_qr.py b/tests/test_cli_qr.py new file mode 100644 index 00000000..5d62fa42 --- /dev/null +++ b/tests/test_cli_qr.py @@ -0,0 +1,265 @@ +"""Terminal QR rendering and local wireless debugging at the CLI boundary.""" + +import io +import re +import subprocess +import sys +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from click.testing import CliRunner + +from mobilerun.cli import auth, 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 = qr.segno.make_qr(payload) + make = Mock(return_value=code) + monkeypatch.setattr(qr.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 + # Segno's compact output renders light modules as full blocks: two + # solid lines preserve the four-module quiet zone above the symbol. + assert lines[:2] == ["█" * code.symbol_size()[0]] * 2 + assert any("█" in line or "▀" in line or "▄" in line for line in lines) + + +@pytest.mark.parametrize("no_qr,complete", [(False, True), (True, True), (False, False)]) +def test_login_qr_keeps_existing_flow_and_stdout(monkeypatch, tmp_path, no_qr, complete): + import webbrowser + + link = "https://example.test/verify?user_code=ABCD-EFGH" if complete else "" + 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) + args = ["auth", "login"] + (["--no-qr"] if no_qr else []) + result = TerminalRunner().invoke(cli, args) + assert result.exit_code == 0, result.output + assert result.stdout == "" + assert "ABCD-EFGH" in result.stderr and "Visit:" in result.stderr + if complete and not no_qr: + printed.assert_called_once_with(link) + assert "█" in result.stderr or "▀" in result.stderr or "▄" in result.stderr + else: + printed.assert_not_called() + if complete: + browser.assert_called_once_with(link) + else: + browser.assert_not_called() + request.assert_called_once_with(auth.DEFAULT_AUTH_URL, auth.DEFAULT_DEVICE_CLIENT_ID) + poll.assert_called_once_with(auth.DEFAULT_AUTH_URL, auth.DEFAULT_DEVICE_CLIENT_ID, code) + 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"), + ("ipv6", "_adb-tls-connect._tcp", "[2001:db8::1]:30004"), + ] + assert services[-1].ip == "[2001:db8::1]" + + +@pytest.fixture +def pairing(monkeypatch): + monkeypatch.setattr(device_pair.shutil, "which", lambda _: "/fake/adb") + 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, "Successfully paired to 192.0.2.1:32001", ""), + (0, "unrelated\t_adb-tls-connect._tcp\t192.0.2.2:33001", ""), + (0, "adb-device\t_adb-tls-connect._tcp\t192.0.2.1:33002", ""), + (0, "connected to 192.0.2.1:33002", ""), + ] + 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) + + +def test_pair_sequences_discovery_pair_and_connect_without_exposing_password(pairing): + result = TerminalRunner().invoke(cli, ["device", "pair", "-v"]) + assert result.exit_code == 0, result.output + assert result.stdout == "192.0.2.1:33002\n" + assert "Paired and connected" in result.stderr + assert "mobilerun device setup -d 192.0.2.1:33002" in result.stderr + assert pairing.printed.call_args.args == ("WIFI:T:ADB;S:mobilerun-a1b2c3;P:Ab12Cd34Ef;;",) + assert re.fullmatch(r"WIFI:T:ADB;S:mobilerun-[a-z0-9]{6};P:[a-zA-Z0-9]{10};;", + pairing.printed.call_args.args[0]) + assert [args for args, _ in pairing.calls] == [ + ("mdns", "check"), ("mdns", "services"), ("mdns", "services"), + ("pair", "192.0.2.1:32001"), ("mdns", "services"), ("mdns", "services"), + ("connect", "192.0.2.1:33002"), + ] + 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 i, (_, kwargs) in enumerate(pairing.calls) if i != 3) + assert "Ab12Cd34Ef" not in result.output + assert pairing.clock.now == 2 + + +@pytest.mark.parametrize("stage", ["pairing", "connection"]) +def test_pair_timeout(pairing, stage): + if stage == "pairing": + pairing.responses[:] = pairing.responses[:2] + limit = 1 + else: + pairing.responses[:] = pairing.responses[:5] + limit = 2 + 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("stage,index", [("pair", 3), ("connect", 6)]) +@pytest.mark.parametrize("returncode", [0, 1]) +def test_pair_adb_failure_redacts_password(pairing, stage, index, returncode): + pairing.responses[index] = (returncode, f"Failed {stage}: Ab12Cd34Ef", "") + result = TerminalRunner().invoke(cli, ["device", "pair", "-v"]) + assert result.exit_code == 1 + assert f"Failed {stage}: [redacted]" 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.shutil, "which", lambda _: None) + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 1 + assert "adb is missing" 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, + ) diff --git a/uv.lock b/uv.lock index b84a17c6..06effccf 100644 --- a/uv.lock +++ b/uv.lock @@ -1430,6 +1430,7 @@ dependencies = [ { name = "pydantic" }, { name = "pyjwt", extra = ["crypto"] }, { name = "rich" }, + { name = "segno" }, { name = "urllib3" }, ] @@ -1508,6 +1509,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 = "urllib3", specifier = ">=2.8.0" }, ] provides-extras = ["anthropic", "deepseek", "dev", "langfuse"] @@ -2782,6 +2784,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" From 01267cfc98f6a55eb14b748cfffa058a1fa69d31 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 18:43:21 +0100 Subject: [PATCH 14/59] fix(config): use platform tokens --- README.md | 3 ++- mobilerun/agent/utils/android_device.py | 2 +- mobilerun/cli/env.py | 6 +++++- mobilerun/cli/selection.py | 7 +++++-- mobilerun/config_example.yaml | 2 +- mobilerun/config_manager/config_manager.py | 20 ++++++++++++++++---- tests/test_android_portal_http_wiring.py | 12 ++++++------ tests/test_cli_contract.py | 12 +++++------- tests/test_ios_local_portal_wiring.py | 14 +++++++------- 9 files changed, 48 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 7a683841..87014041 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,8 @@ share these provider names: `openai`, `openai-oauth`, `anthropic`, | `MOBILERUN_API_BASE_URL` | Cloud API URL | `https://api.mobilerun.ai/v1`; `--api-url` | | `MOBILERUN_AUTH_URL` | Sign-in URL | `https://cloud.mobilerun.ai/api/auth`; `--auth-url` | | `MOBILERUN_CONFIG` | Config file | User config directory; `-c`/`--config` | -| `MOBILERUN_DEVICE_TOKEN` | Portal HTTP token | Configured token, if any | +| `MOBILERUN_ANDROID_PORTAL_TOKEN` | Android Portal HTTP token | Configured token, if any | +| `MOBILERUN_IOS_PORTAL_TOKEN` | iOS Portal HTTP token | Configured token, if any | | `MOBILERUN_DEBUG` | Verbose logs | Off; `-v`/`--verbose` | | `MOBILERUN_TELEMETRY_ENABLED` | Anonymous telemetry | `true`, subject to config policy | 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/cli/env.py b/mobilerun/cli/env.py index 0ebe1178..edfec0ab 100644 --- a/mobilerun/cli/env.py +++ b/mobilerun/cli/env.py @@ -17,6 +17,7 @@ ) from mobilerun.cli.selection import resolve_cloud_api_key from mobilerun.config_manager import ConfigLoader, MobileConfig +from mobilerun.config_manager.config_manager import PORTAL_TOKEN_ENV_VARS, resolve_portal_token from mobilerun.config_manager.env_keys import API_KEY_ENV_VARS, load_env_key_sources from mobilerun.telemetry.tracker import is_telemetry_enabled @@ -49,7 +50,6 @@ def resolved_environment(config_path=None): False, ), "MOBILERUN_CONFIG": (str(path), path_source, False), - "MOBILERUN_DEVICE_TOKEN": (config.device.auth_token, config_source if config.device.auth_token else "not set", True), "MOBILERUN_DEBUG": (config.logging.debug, config_source, False), "MOBILERUN_TELEMETRY_ENABLED": (config.telemetry.enabled, config_source, False), "MOBILERUN_STREAM_SCREENSHOTS": (False, "default", False), @@ -59,6 +59,10 @@ def resolved_environment(config_path=None): "PHOENIX_PROJECT_NAME": ("", "default", False), "NO_COLOR": (None, "default", False), } + for platform, name in PORTAL_TOKEN_ENV_VARS.items(): + configured = config.device.auth_token if config.device.platform == platform else None + value = resolve_portal_token(platform, configured) + values[name] = (value, "env" if os.environ.get(name) else config_source if configured else "not set", True) sources = load_env_key_sources() for slot, name in API_KEY_ENV_VARS.items(): source = sources[slot] diff --git a/mobilerun/cli/selection.py b/mobilerun/cli/selection.py index f2fffff5..a8a6b22d 100644 --- a/mobilerun/cli/selection.py +++ b/mobilerun/cli/selection.py @@ -14,6 +14,7 @@ ) from mobilerun.cli.options import DEFAULT_API_URL from mobilerun.config_manager import ConfigLoader, MobileConfig +from mobilerun.config_manager.config_manager import PORTAL_TOKEN_ENV_VARS def resolve_cloud_api_key(): @@ -95,9 +96,11 @@ def require_cloud_api_key(): def portal_token(selection): - token = selection.config.device.resolve_auth_token() + token = selection.config.device.resolve_auth_token(selection.platform) if selection.backend == "local-android-http" and not token: - raise click.ClickException("Portal URL requires MOBILERUN_DEVICE_TOKEN or device.auth_token in config") + raise click.ClickException( + f"Portal URL requires {PORTAL_TOKEN_ENV_VARS[selection.platform]} or device.auth_token in config" + ) return token diff --git a/mobilerun/config_example.yaml b/mobilerun/config_example.yaml index 21f498b6..2ce3bde7 100644 --- a/mobilerun/config_example.yaml +++ b/mobilerun/config_example.yaml @@ -148,7 +148,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/config_manager.py b/mobilerun/config_manager/config_manager.py index 3fb6c299..d1d0a13d 100644 --- a/mobilerun/config_manager/config_manager.py +++ b/mobilerun/config_manager/config_manager.py @@ -5,6 +5,7 @@ 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 +20,17 @@ 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, +} + + +def resolve_portal_token(platform: str, configured: str | None = None) -> str | None: + """Resolve a platform's Portal token from the environment, then config.""" + return os.environ.get(PORTAL_TOKEN_ENV_VARS[platform.lower()]) or configured + + # ---------- Config Schema ---------- @dataclass class LLMProfile: @@ -178,12 +190,12 @@ 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]: + return resolve_portal_token(platform or self.platform, self.auth_token) @dataclass diff --git a/tests/test_android_portal_http_wiring.py b/tests/test_android_portal_http_wiring.py index e6d18743..77c354db 100644 --- a/tests/test_android_portal_http_wiring.py +++ b/tests/test_android_portal_http_wiring.py @@ -36,7 +36,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) @@ -107,7 +107,7 @@ def connect(self, device_id, **kwargs): return object() monkeypatch.setattr(Mobilerun, "connect", connect) - monkeypatch.setenv("MOBILERUN_DEVICE_TOKEN", "sekret") + 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 @@ -121,7 +121,7 @@ def test_device_commands_url_without_token_fails_cleanly(tmp_path, monkeypatch, from mobilerun.cli.main import cli - monkeypatch.delenv("MOBILERUN_DEVICE_TOKEN", raising=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 @@ -142,7 +142,7 @@ def ui(self): return Core() monkeypatch.setattr(Mobilerun, "connect", connect) - monkeypatch.setenv("MOBILERUN_DEVICE_TOKEN", "sekret") + monkeypatch.setenv("MOBILERUN_ANDROID_PORTAL_TOKEN", "sekret") result = CliRunner().invoke(cli, ["device", "ping", "--device", PORTAL_URL]) assert result.exit_code == 0, result.output assert "reachable" in result.output @@ -200,7 +200,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), @@ -252,7 +252,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 index a3317e22..165e4acd 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -122,7 +122,7 @@ def test_auth_url_env_and_flag_precedence(monkeypatch): @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_DEVICE_TOKEN", "OPENAI_API_KEY"): + 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"}}) @@ -140,7 +140,7 @@ def test_env_sources_and_secret_config(monkeypatch, tmp_path): 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_DEVICE_TOKEN"]["value"] == "set" + 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)} @@ -478,12 +478,10 @@ 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_DEVICE_TOKEN", raising=False) - monkeypatch.setenv("MOBILERUN_ANDROID_PORTAL_TOKEN", "unrelated-token") + 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_DEVICE_TOKEN" in result.stderr - assert "MOBILERUN_ANDROID_PORTAL_TOKEN" not in result.stderr + assert "MOBILERUN_ANDROID_PORTAL_TOKEN" in result.stderr connect.assert_not_called() @@ -498,7 +496,7 @@ def test_env_path_and_unset_sources_and_effective_telemetry(monkeypatch, tmp_pat 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_DEVICE_TOKEN"]["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) diff --git a/tests/test_ios_local_portal_wiring.py b/tests/test_ios_local_portal_wiring.py index e4ba7bc2..0cd9747e 100644 --- a/tests/test_ios_local_portal_wiring.py +++ b/tests/test_ios_local_portal_wiring.py @@ -10,22 +10,22 @@ 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 = { @@ -134,7 +134,7 @@ def connect(self, device_id, **kwargs): created.update(kwargs) return object() monkeypatch.setattr(Mobilerun, "connect", connect) - monkeypatch.setenv("MOBILERUN_DEVICE_TOKEN", "sekret") + monkeypatch.setenv("MOBILERUN_IOS_PORTAL_TOKEN", "sekret") cfg = tmp_path / "config.yaml" cfg.write_text("_version: 7\ndevice:\n platform: ios\n") with device_commands.device_driver({"device": "http://127.0.0.1:8080", "config_path": str(cfg), From 392efad46c8019ded1f6310608703b3d2c1c2c51 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 18:43:22 +0100 Subject: [PATCH 15/59] feat(cli): format ui elements --- mobilerun/cli/device_commands.py | 48 +++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index 6c66d3f7..fcafdcb9 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -7,6 +7,7 @@ from contextlib import contextmanager import click +from mobilerun_core._a11y import node_bounds from mobilerun.agent.utils.android_device import is_portal_http_url from mobilerun.cli.options import Group, device_options, emit, json_option, verbose_option @@ -70,14 +71,53 @@ def screenshot(json_output, **params): emit({"path": path} if json_output else path, json_output=json_output) +def _ui_cell(value): + return str(value or "").replace("\t", " ").replace("\n", " ").replace("\r", " ") + + +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 ("clickable", "checkable", "checked", "editable", "focused", "scrollable", "selected") + if node.get(flag) or node.get("is" + flag.capitalize()) or node.get("is_" + flag)] + if node.get("disabled") or any(node.get(key) is False for key in ("enabled", "isEnabled", "is_enabled")): + flags.append("disabled") + text = node.get("text") or node.get("contentDescription") or node.get("content_description") or "" + resource = node.get("resourceId") or node.get("resource_id") or node.get("accessibilityIdentifier") or "" + if not (all_elements or text or resource or flags): + continue + class_name = node.get("className") or node.get("class_name") or "" + bounds = node_bounds(node) or {} + if isinstance(bounds, dict): + if all(key in bounds for key in ("x", "y", "width", "height")): + bounds = (bounds["x"], bounds["y"], bounds["x"] + bounds["width"], bounds["y"] + bounds["height"]) + else: + bounds = tuple(bounds.get(key, "") for key in ("left", "top", "right", "bottom")) if bounds else () + yield "\t".join((str(index), _ui_cell(class_name.rsplit(".", 1)[-1]), _ui_cell(text), + _ui_cell(resource), ",".join(str(value) for value in bounds) if bounds else "", + ",".join(flags))) + + @device_cli.command() @json_option +@click.option("--all", "all_elements", is_flag=True, help="Include empty layout containers.") @device_options -def ui(json_output, **params): - """Print the UI state returned by the device.""" +def ui(json_output, all_elements, **params): + """Print tab-separated UI elements, or the raw state with --json.""" with device_driver(params) as device: - state = json.loads(device.ui_json()) - emit(state if json_output else json.dumps(state, indent=2), json_output=json_output) + if json_output: + emit(json.loads(device.ui_json()), json_output=True) + return + state = device.ui(filter=False) + 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() From eb6ca68c66b5fd061a9d60387abade8696ec90b6 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 18:43:22 +0100 Subject: [PATCH 16/59] fix(cli): simplify api errors --- mobilerun/cli/options.py | 71 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index ad14c28f..60c89efa 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -2,17 +2,72 @@ import asyncio import json +import os import sys import traceback from contextlib import redirect_stdout from functools import wraps +from urllib.parse import urlsplit, urlunsplit import click +from mobilerun_sdk import APIConnectionError, APIStatusError, APITimeoutError DEFAULT_API_URL = "https://api.mobilerun.ai/v1" CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} +def convert_api_error(exc, ctx): + """Return safe text, JSON and optional diagnostics for an SDK error.""" + seen = set() + while exc is not None and id(exc) not in seen: + seen.add(id(exc)) + if isinstance(exc, (APIStatusError, APIConnectionError)): + break + exc = exc.__cause__ + else: + return None + request = exc.request + sensitive = [value for name, value in request.headers.items() if name != "host"] + authorization = request.headers.get("authorization", "") + if authorization.lower().startswith("bearer "): + sensitive.append(authorization[7:]) + sensitive.append(os.environ.get("MOBILERUN_CLOUD_API_KEY", "")) + + def clean(value): + text = " ".join(str(value).split()) + for secret in sorted(filter(None, sensitive), key=len, reverse=True): + text = text.replace(secret, "[redacted]") + return text + + 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: + message = first if isinstance(first, str) and first.strip() else ( + exc.response.reason_phrase if status is not None else + "Request timed out" if isinstance(exc, APITimeoutError) else "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}: + hint = "Run `mobilerun auth login` or set MOBILERUN_CLOUD_API_KEY." + elif status == 404 and "devices" in request.url.path.split("/"): + hint = "Find device ids with `mobilerun device list -t cloud`." + elif status is None: + url = urlsplit(ctx.params.get("api_url") or os.environ.get("MOBILERUN_API_BASE_URL") or DEFAULT_API_URL) + safe_url = urlunsplit((url.scheme, url.netloc.rsplit("@", 1)[-1], url.path, "", "")) + hint = f"Check the API URL: {clean(safe_url)}" + diagnostic = f"{request.method} {clean(request.url.path)}" + return text, document, hint, diagnostic + + class Command(click.Command): def invoke(self, ctx): ctx.ensure_object(dict) @@ -43,13 +98,21 @@ def invoke(self, ctx): except (KeyboardInterrupt, asyncio.CancelledError, click.Abort): ctx.exit(130) except Exception as exc: - if not isinstance(exc, click.ClickException): + converted = convert_api_error(exc, ctx) + if converted: + text, document, hint, diagnostic = converted if ctx.params.get("verbose"): - traceback.print_exc(file=sys.stderr) - exc = click.ClickException(str(exc) or type(exc).__name__) + click.echo(diagnostic, err=True) + exc = click.ClickException(text + (f"\n{hint}" if hint else "")) + else: + if not isinstance(exc, click.ClickException): + if ctx.params.get("verbose"): + traceback.print_exc(file=sys.stderr) + exc = click.ClickException(str(exc) or type(exc).__name__) + document = {"error": exc.message} if (not isinstance(exc, click.UsageError) and ctx.params.get("json_output") and not ctx.obj["emitted"]): - emit({"error": exc.message}, json_output=True) + emit(document, json_output=True) raise exc finally: logger.handlers, level, logger.propagate = previous From cb9e02e7b5727559353817b8b84ae47205c08e41 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 18:43:22 +0100 Subject: [PATCH 17/59] test(cli): verify polish decisions --- tests/test_cli_polish_fix2.py | 189 ++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 tests/test_cli_polish_fix2.py diff --git a/tests/test_cli_polish_fix2.py b/tests/test_cli_polish_fix2.py new file mode 100644 index 00000000..706654bb --- /dev/null +++ b/tests/test_cli_polish_fix2.py @@ -0,0 +1,189 @@ +"""Platform token, UI text and safe SDK-error contracts.""" + +import json +from types import SimpleNamespace +from unittest.mock import Mock + +import httpx +import pytest +from click.testing import CliRunner +from mobilerun_core import UiState +from mobilerun_sdk import APIConnectionError, APIStatusError, APITimeoutError + +from mobilerun.cli import device_lifecycle, selection +from mobilerun.cli.main import cli +from mobilerun.config_manager import ConfigLoader, MobileConfig +from mobilerun.config_manager.config_manager import PORTAL_TOKEN_ENV_VARS, 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 (*PORTAL_TOKEN_ENV_VARS.values(), "MOBILERUN_CONFIG", "MOBILERUN_DEVICE", "MOBILERUN_TARGET"): + monkeypatch.delenv(name, raising=False) + + +@pytest.mark.parametrize("platform", ["android", "ios"]) +def test_platform_tokens_override_config_without_crossing_platforms(monkeypatch, platform): + other = "ios" if platform == "android" else "android" + config = DeviceConfig(platform=platform, auth_token="config-token") + monkeypatch.setenv(PORTAL_TOKEN_ENV_VARS[other], "other-platform-token") + assert config.resolve_auth_token() == "config-token" + monkeypatch.setenv(PORTAL_TOKEN_ENV_VARS[platform], "selected-platform-token") + assert config.resolve_auth_token() == "selected-platform-token" + assert config.resolve_auth_token(other) == "other-platform-token" + selected = selection.Selection(MobileConfig(device=config), "http://localhost:9", other, f"local-{other}-http") + assert selection.portal_token(selected) == "other-platform-token" + + +@pytest.mark.parametrize("platform", ["android", "ios"]) +def test_legacy_token_variable_is_ignored(monkeypatch, 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): + for name in PORTAL_TOKEN_ENV_VARS.values(): + 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 PORTAL_TOKEN_ENV_VARS.values()) + 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 PORTAL_TOKEN_ENV_VARS.values()) + + +def test_env_uses_config_token_only_for_configured_platform(): + config = MobileConfig(device=DeviceConfig(platform="ios", 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[PORTAL_TOKEN_ENV_VARS["ios"]]["value"] == "set" + assert rows[PORTAL_TOKEN_ENV_VARS["ios"]]["source"] == "config" + assert rows[PORTAL_TOKEN_ENV_VARS["android"]]["value"] == "not set" + assert "config-secret-marker" not in result.output + + +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", +] + + +@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_LINES[:] + if all_elements: + expected.insert(0, "0\tFrameLayout\t\t\t0,0,100,200\t") + expected.insert(2, "2\tView\t\t\t\t") + 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.assert_called_once_with(filter=False) + 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", "--all"]) + 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() + + +@pytest.mark.parametrize("kind,status,body,message,code,hint", [ + ("status", 401, {"detail": "Not authorized", "title": "Unauthorized", "code": "unauthorized"}, "Not authorized", "unauthorized", "mobilerun auth login"), + ("status", 403, {"title": "Forbidden"}, "Forbidden", None, "MOBILERUN_CLOUD_API_KEY"), + ("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), + ("connection", None, None, "Could not connect to the API", None, "https://api.example.test/v1"), + ("timeout", None, None, "Request timed out", None, "https://api.example.test/v1"), +]) +@pytest.mark.parametrize("json_output", [False, True]) +@pytest.mark.parametrize("wrapped", [False, True]) +def test_api_errors_are_safe_and_consistent(monkeypatch, kind, status, body, message, code, hint, json_output, wrapped): + marker = "private-key-marker" + header_marker = "private-header-marker" + request = httpx.Request("GET", f"https://api.example.test/v1/devices/missing?token={marker}", + headers={"Authorization": f"Bearer {marker}", "X-Private": header_marker}) + if status is not None: + body = {**body, "raw_secret": marker, "request_headers": dict(request.headers)} + error = APIStatusError(f"raw body {body!r}", response=httpx.Response(status, request=request), body=body) + elif kind == "timeout": + error = APITimeoutError(request) + else: + error = APIConnectionError(message=f"raw connection {marker} {header_marker}", request=request) + def fail(**kwargs): + if wrapped: + raise RuntimeError(f"core wrapper {marker}") from error + raise error + monkeypatch.setattr(device_lifecycle, "core_client", lambda *a, **kw: SimpleNamespace(list_devices=fail)) + monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", marker) + flags = ["-j"] if json_output else [] + result = CliRunner().invoke(cli, ["device", "list", "-t", "cloud", "--api-url", "https://api.example.test/v1", "-v", *flags]) + assert result.exit_code == 1, result.output + assert marker not in result.output and header_marker not in result.output + assert "Authorization" not in result.output and "request_headers" not in result.output + assert "raw body" not in result.output and "core wrapper" not in result.output + assert "Traceback" not in result.output and "?token" not in result.output + assert "GET /v1/devices/missing\n" in result.stderr + assert f"Error: {message}" + (f" (HTTP {status})" if status else "") in result.stderr + if hint: + assert hint in result.stderr + if json_output: + assert json.loads(result.stdout) == {"error": message, "status": status, "code": code} + else: + assert result.stdout == "" + + +def test_api_error_message_redacts_echoed_key(monkeypatch): + marker = "echoed-private-key" + request = httpx.Request("GET", "https://api.example.test/v1/devices", headers={"Authorization": f"Bearer {marker}"}) + body = {"detail": f"Invalid credential {marker}"} + error = APIStatusError("do not print raw body", response=httpx.Response(401, request=request), body=body) + monkeypatch.setattr(device_lifecycle, "core_client", lambda *a, **kw: SimpleNamespace(list_devices=Mock(side_effect=error))) + result = CliRunner().invoke(cli, ["device", "list", "-t", "cloud", "-j"]) + assert result.exit_code == 1 + assert marker not in result.output + assert json.loads(result.stdout)["error"] == "Invalid credential [redacted]" From d563c542ac7ba43fd748854dd123630dccfaa877 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 19:05:00 +0100 Subject: [PATCH 18/59] fix(cli): harden qr pairing --- mobilerun/cli/auth.py | 6 +- mobilerun/cli/device_lifecycle.py | 6 +- mobilerun/cli/device_pair.py | 74 ++++++----- mobilerun/cli/qr.py | 13 +- tests/test_cli_qr.py | 207 ++++++++++++++++++++++-------- 5 files changed, 209 insertions(+), 97 deletions(-) diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py index 90fe9aab..5b35025f 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -83,7 +83,7 @@ def _run_grok_oauth_login(credential_path: str, model: str | None = None, **kwar DEFAULT_DEVICE_CLIENT_ID = "mobilerun-cli" -def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool = False): +def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool): """Authenticate via OAuth 2.0 device authorization and save the session token.""" import webbrowser @@ -94,9 +94,9 @@ def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool = False): console.print(f"Visit: [bold]{code.verification_uri}[/]") console.print(f"Code: [bold cyan]{code.user_code}[/]") - if code.verification_uri_complete and not no_qr: - print_qr(code.verification_uri_complete) if code.verification_uri_complete: + if not no_qr: + print_qr(code.verification_uri_complete) console.print(f"[dim](Opening {code.verification_uri_complete} ...)[/]") try: webbrowser.open(code.verification_uri_complete) diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index 187a9c54..48c8948e 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -4,7 +4,7 @@ from async_adbutils import adb from mobilerun.cli.device_commands import device_cli, probe_device -from mobilerun.cli.device_pair import pair +from mobilerun.cli.device_pair import adb_connect_succeeded from mobilerun.cli.options import ( api_url_option, config_option, @@ -19,8 +19,6 @@ from mobilerun.cli.selection import core_client, select_device from mobilerun.cli.setup import _setup_portal -device_cli.add_command(pair) - @device_cli.command("list") @target_option @@ -67,7 +65,7 @@ def list_devices(target, api_url, verbose, json_output, all_devices): async def connect(serial, verbose): """Connect to an Android device over TCP/IP.""" result = await adb.connect(serial) - if "connected to" not in result: + 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) diff --git a/mobilerun/cli/device_pair.py b/mobilerun/cli/device_pair.py index 8fd88317..5793d726 100644 --- a/mobilerun/cli/device_pair.py +++ b/mobilerun/cli/device_pair.py @@ -6,11 +6,13 @@ import subprocess import sys import time +from ipaddress import ip_address from typing import NamedTuple import click -from mobilerun.cli.options import CONTEXT_SETTINGS, Command, emit, verbose_option +from mobilerun.cli.device_commands import device_cli +from mobilerun.cli.options import emit, verbose_option from mobilerun.cli.qr import print_qr @@ -33,12 +35,18 @@ def parse_mdns_services(output: str) -> list[MdnsService]: continue name, service_type, address = fields service_type = service_type.rstrip(".") - if service_type not in {"_adb-tls-pairing._tcp", "_adb-tls-connect._tcp"}: + host, _, port = address.rpartition(":") + if not port.isdigit() or not 0 < int(port) < 65536: continue - host, separator, port = address.rpartition(":") - if host and separator and port.isdigit() and 0 < int(port) < 65536: + 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 services + return sorted(services, key=lambda service: service.address.startswith("[")) def _run_adb( @@ -51,7 +59,17 @@ def _run_adb( ) -def _adb(*args: str, deadline: float, password: str = "", input: str | None = None): +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.") @@ -60,34 +78,31 @@ def _adb(*args: str, deadline: float, password: str = "", input: str | None = No 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 password: - result.stdout = result.stdout.replace(password, "[redacted]") - result.stderr = result.stderr.replace(password, "[redacted]") + 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 click.ClickException( - result.stderr.strip() or result.stdout.strip() or "adb command failed." - ) + raise _adb_failure(result, "adb command failed.") return result def _wait_for_service( - service_type: str, deadline: float, password: str, *, name: str | None = None, + service_type: str, deadline: float, *, name: str | None = None, ip: str | None = None, ) -> MdnsService: while True: - result = _adb("mdns", "services", deadline=deadline, password=password) + result = _adb("mdns", "services", deadline=deadline) for service in parse_mdns_services(result.stdout): if (service.service_type == service_type and (name is None or service.name == name) and (ip is None or service.ip == ip)): return service - remaining = deadline - time.monotonic() - if remaining <= 0: - raise click.ClickException("Timed out waiting for wireless debugging.") - time.sleep(min(1, remaining)) + time.sleep(1) -@click.command(cls=Command, context_settings=CONTEXT_SETTINGS) +@device_cli.command() @verbose_option @click.option( "--timeout", type=click.IntRange(min=1), default=120, show_default=True, @@ -114,25 +129,20 @@ def pair(timeout: int, verbose: bool | None): err=True, ) if not print_qr(f"WIFI:T:ADB;S:{name};P:{password};;"): - raise click.UsageError("This command needs a terminal.") + raise click.ClickException("Could not display the pairing QR code.") pairing = _wait_for_service( - "_adb-tls-pairing._tcp", deadline, password, name=name + "_adb-tls-pairing._tcp", deadline, name=name ) result = _adb("pair", pairing.address, input=password + "\n", - deadline=deadline, password=password) + deadline=deadline) if "successfully paired to" not in result.stdout.lower(): - raise click.ClickException( - result.stderr.strip() or result.stdout.strip() or "adb pairing failed." - ) + raise _adb_failure(result, "adb pairing failed.") connection = _wait_for_service( - "_adb-tls-connect._tcp", deadline, password, ip=pairing.ip + "_adb-tls-connect._tcp", deadline, ip=pairing.ip ) - result = _adb("connect", connection.address, deadline=deadline, password=password) - if not any(line.lower().startswith(("connected to ", "already connected to ")) - for line in result.stdout.splitlines()): - raise click.ClickException( - result.stderr.strip() or result.stdout.strip() or "adb connection failed." - ) + result = _adb("connect", connection.address, deadline=deadline) + if not adb_connect_succeeded(result.stdout): + raise _adb_failure(result, "adb connection failed.") emit(connection.address) click.echo( f"Paired and connected. Run `mobilerun device setup -d {connection.address}`.", diff --git a/mobilerun/cli/qr.py b/mobilerun/cli/qr.py index a2140a44..51d3a354 100644 --- a/mobilerun/cli/qr.py +++ b/mobilerun/cli/qr.py @@ -1,13 +1,22 @@ """Shared terminal QR rendering.""" import sys +from io import StringIO import segno def print_qr(data: str) -> bool: """Print a compact QR with its quiet zone to terminal stderr.""" - if not sys.stderr.isatty(): + 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 - segno.make_qr(data).terminal(out=sys.stderr, compact=True) return True diff --git a/tests/test_cli_qr.py b/tests/test_cli_qr.py index 26e0a741..22002305 100644 --- a/tests/test_cli_qr.py +++ b/tests/test_cli_qr.py @@ -1,17 +1,16 @@ """Terminal QR rendering and local wireless debugging at the CLI boundary.""" import io -import re import subprocess import sys from contextlib import contextmanager from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock import pytest from click.testing import CliRunner -from mobilerun.cli import auth, device_pair, deviceauth, options, qr +from mobilerun.cli import auth, device_lifecycle, device_pair, deviceauth, qr from mobilerun.cli.main import cli from mobilerun.config_manager import ConfigLoader @@ -50,17 +49,35 @@ def test_print_qr_uses_stderr_and_preserves_quiet_zone(monkeypatch): make.assert_called_once_with(payload) lines = output.getvalue().splitlines() assert len(lines) > 10 - # Segno's compact output renders light modules as full blocks: two - # solid lines preserve the four-module quiet zone above the symbol. - assert lines[:2] == ["█" * code.symbol_size()[0]] * 2 - assert any("█" in line or "▀" in line or "▄" in line for line in lines) + 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("no_qr,complete", [(False, True), (True, True), (False, False)]) -def test_login_qr_keeps_existing_flow_and_stdout(monkeypatch, tmp_path, no_qr, complete): +@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(qr.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 - link = "https://example.test/verify?user_code=ABCD-EFGH" if complete else "" + 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) @@ -74,23 +91,51 @@ def test_login_qr_keeps_existing_flow_and_stdout(monkeypatch, tmp_path, no_qr, c monkeypatch.setattr(webbrowser, "open", browser) printed = Mock(side_effect=qr.print_qr) monkeypatch.setattr(auth, "print_qr", printed) - args = ["auth", "login"] + (["--no-qr"] if no_qr else []) - result = TerminalRunner().invoke(cli, args) + 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 - if complete and not no_qr: - printed.assert_called_once_with(link) - assert "█" in result.stderr or "▀" in result.stderr or "▄" in result.stderr - else: - printed.assert_not_called() - if complete: - browser.assert_called_once_with(link) - else: - browser.assert_not_called() - request.assert_called_once_with(auth.DEFAULT_AUTH_URL, auth.DEFAULT_DEVICE_CLIENT_ID) - poll.assert_called_once_with(auth.DEFAULT_AUTH_URL, auth.DEFAULT_DEVICE_CLIENT_ID, code) - save.assert_called_once_with("test-session", auth.DEFAULT_AUTH_URL) + login.printed.assert_called_once_with("https://example.test/verify?user_code=ABCD-EFGH") + assert "\x1b[97;40m" in result.stderr + login.browser.assert_called_once_with("https://example.test/verify?user_code=ABCD-EFGH") + 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_browser_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_called_once_with("https://example.test/verify?user_code=ABCD-EFGH") + 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(qr.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_called_once_with("https://example.test/verify?user_code=ABCD-EFGH") + 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"]) @@ -116,11 +161,22 @@ def test_mdns_parser_reads_services_and_ignores_header(): ("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"), ] assert services[-1].ip == "[2001:db8::1]" +@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): monkeypatch.setattr(device_pair.shutil, "which", lambda _: "/fake/adb") @@ -137,7 +193,7 @@ def pairing(monkeypatch): (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, "Successfully paired to 192.0.2.1:32001", ""), + (0, "Enter pairing code: Successfully paired to 192.0.2.1:32001 [guid=adb-device]", ""), (0, "unrelated\t_adb-tls-connect._tcp\t192.0.2.2:33001", ""), (0, "adb-device\t_adb-tls-connect._tcp\t192.0.2.1:33002", ""), (0, "connected to 192.0.2.1:33002", ""), @@ -160,8 +216,6 @@ def test_pair_sequences_discovery_pair_and_connect_without_exposing_password(pai assert "Paired and connected" in result.stderr assert "mobilerun device setup -d 192.0.2.1:33002" in result.stderr assert pairing.printed.call_args.args == ("WIFI:T:ADB;S:mobilerun-a1b2c3;P:Ab12Cd34Ef;;",) - assert re.fullmatch(r"WIFI:T:ADB;S:mobilerun-[a-z0-9]{6};P:[a-zA-Z0-9]{10};;", - pairing.printed.call_args.args[0]) assert [args for args, _ in pairing.calls] == [ ("mdns", "check"), ("mdns", "services"), ("mdns", "services"), ("pair", "192.0.2.1:32001"), ("mdns", "services"), ("mdns", "services"), @@ -169,19 +223,73 @@ def test_pair_sequences_discovery_pair_and_connect_without_exposing_password(pai ] 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 i, (_, kwargs) in enumerate(pairing.calls) if i != 3) + 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("stage", ["pairing", "connection"]) -def test_pair_timeout(pairing, stage): - if stage == "pairing": - pairing.responses[:] = pairing.responses[:2] - limit = 1 - else: - pairing.responses[:] = pairing.responses[:5] - limit = 2 +def test_pair_prefers_ipv4_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", "") + pairing.responses[5] = (0, + "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", "") + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 0, result.output + assert result.stdout == "192.0.2.1:33002\n" + assert pairing.calls[3][0] == ("pair", "192.0.2.1:32001") + assert pairing.calls[-1][0] == ("connect", "192.0.2.1:33002") + + +def test_pair_accepts_bracketed_global_ipv6_when_no_ipv4_exists(pairing): + pairing.responses[2] = (0, + "mobilerun-a1b2c3\t_adb-tls-pairing._tcp\t[2001:db8::1]:32001", "") + pairing.responses[5] = (0, + "adb-device\t_adb-tls-connect._tcp\t[2001:db8::1]:33002", "") + result = TerminalRunner().invoke(cli, ["device", "pair"]) + assert result.exit_code == 0, result.output + assert result.stdout == "[2001:db8::1]:33002\n" + assert pairing.calls[3][0] == ("pair", "[2001:db8::1]:32001") + assert pairing.calls[-1][0] == ("connect", "[2001:db8::1]:33002") + + +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, pairing): + pairing.responses[-1] = (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(qr.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 @@ -189,34 +297,21 @@ def test_pair_timeout(pairing, stage): assert pairing.clock.now == limit -@pytest.mark.parametrize("stage,index", [("pair", 3), ("connect", 6)]) +@pytest.mark.parametrize("stage,index,message,expected", [ + ("pair", 3, "Failed pair: Ab12Cd34Ef", "Failed pair: [redacted]"), + ("connect", 6, "Failed connect: refused", "Failed connect: refused"), +]) @pytest.mark.parametrize("returncode", [0, 1]) -def test_pair_adb_failure_redacts_password(pairing, stage, index, returncode): - pairing.responses[index] = (returncode, f"Failed {stage}: Ab12Cd34Ef", "") +def test_pair_adb_failure_redacts_password(pairing, stage, index, message, expected, returncode): + pairing.responses[index] = (returncode, message, "") result = TerminalRunner().invoke(cli, ["device", "pair", "-v"]) assert result.exit_code == 1 - assert f"Failed {stage}: [redacted]" in result.stderr + assert expected in result.stderr assert "Ab12Cd34Ef" not in result.output assert result.stdout == "" assert pairing.calls[-1][0][0] == stage -def test_pair_errors_use_shared_command_handler(monkeypatch, pairing): - pairing.responses[3] = (1, "", "Pairing failed: Ab12Cd34Ef") - convert = Mock(wraps=options.convert_api_error) - monkeypatch.setattr(options, "convert_api_error", convert) - result = TerminalRunner().invoke(cli, ["device", "pair"]) - assert result.exit_code == 1 - assert result.stdout == "" - assert "Pairing failed: [redacted]" in result.stderr - assert "Ab12Cd34Ef" not in result.output - convert.assert_called_once() - error, context = convert.call_args.args - assert error.message == "Pairing failed: [redacted]" - assert context.command is cli.commands["device"].commands["pair"] - assert isinstance(context.command, options.Command) - - def test_pair_requires_mdns(pairing): pairing.responses[0] = (0, "ERROR: mdns discovery disabled", "") result = TerminalRunner().invoke(cli, ["device", "pair"]) From 192da17cbf6642e2ac3d27669fe549446b1bbae4 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 19:08:38 +0100 Subject: [PATCH 19/59] fix(config): isolate platform tokens --- mobilerun/cli/env.py | 8 ++++---- mobilerun/cli/run.py | 2 ++ mobilerun/config_manager/config_manager.py | 13 +++++++------ 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/mobilerun/cli/env.py b/mobilerun/cli/env.py index edfec0ab..00e0a5dd 100644 --- a/mobilerun/cli/env.py +++ b/mobilerun/cli/env.py @@ -17,7 +17,7 @@ ) from mobilerun.cli.selection import resolve_cloud_api_key from mobilerun.config_manager import ConfigLoader, MobileConfig -from mobilerun.config_manager.config_manager import PORTAL_TOKEN_ENV_VARS, resolve_portal_token +from mobilerun.config_manager.config_manager import PORTAL_TOKEN_ENV_VARS from mobilerun.config_manager.env_keys import API_KEY_ENV_VARS, load_env_key_sources from mobilerun.telemetry.tracker import is_telemetry_enabled @@ -60,9 +60,9 @@ def resolved_environment(config_path=None): "NO_COLOR": (None, "default", False), } for platform, name in PORTAL_TOKEN_ENV_VARS.items(): - configured = config.device.auth_token if config.device.platform == platform else None - value = resolve_portal_token(platform, configured) - values[name] = (value, "env" if os.environ.get(name) else config_source if configured else "not set", True) + 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] diff --git a/mobilerun/cli/run.py b/mobilerun/cli/run.py index 936d39a3..96805f83 100644 --- a/mobilerun/cli/run.py +++ b/mobilerun/cli/run.py @@ -139,6 +139,8 @@ async def _run_task( # 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 diff --git a/mobilerun/config_manager/config_manager.py b/mobilerun/config_manager/config_manager.py index d1d0a13d..696c361c 100644 --- a/mobilerun/config_manager/config_manager.py +++ b/mobilerun/config_manager/config_manager.py @@ -26,11 +26,6 @@ } -def resolve_portal_token(platform: str, configured: str | None = None) -> str | None: - """Resolve a platform's Portal token from the environment, then config.""" - return os.environ.get(PORTAL_TOKEN_ENV_VARS[platform.lower()]) or configured - - # ---------- Config Schema ---------- @dataclass class LLMProfile: @@ -195,7 +190,13 @@ class DeviceConfig: auth_token: Optional[str] = None def resolve_auth_token(self, platform: str | None = None) -> Optional[str]: - return resolve_portal_token(platform or self.platform, self.auth_token) + """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 From 9398c6c0132bb7d790a606dd83e5978853557b14 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 19:08:39 +0100 Subject: [PATCH 20/59] fix(cli): preserve api messages --- mobilerun/cli/options.py | 45 +++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index 60c89efa..e8bd6804 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -7,7 +7,6 @@ import traceback from contextlib import redirect_stdout from functools import wraps -from urllib.parse import urlsplit, urlunsplit import click from mobilerun_sdk import APIConnectionError, APIStatusError, APITimeoutError @@ -16,22 +15,24 @@ CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} -def convert_api_error(exc, ctx): - """Return safe text, JSON and optional diagnostics for an SDK error.""" - seen = set() - while exc is not None and id(exc) not in seen: - seen.add(id(exc)) +def _sdk_error(exc): + while exc is not None: if isinstance(exc, (APIStatusError, APIConnectionError)): - break + return exc exc = exc.__cause__ - else: + return None + + +def convert_api_error(exc): + """Return safe text, JSON and method/path diagnostics for an SDK error.""" + exc = _sdk_error(exc) + if exc is None: return None request = exc.request - sensitive = [value for name, value in request.headers.items() if name != "host"] + sensitive = [os.environ.get("MOBILERUN_CLOUD_API_KEY", "")] authorization = request.headers.get("authorization", "") if authorization.lower().startswith("bearer "): sensitive.append(authorization[7:]) - sensitive.append(os.environ.get("MOBILERUN_CLOUD_API_KEY", "")) def clean(value): text = " ".join(str(value).split()) @@ -48,10 +49,12 @@ def clean(value): code = next((entry["code"] for entry in fields if isinstance(entry.get("code"), (str, int))), None) if message is None: - message = first if isinstance(first, str) and first.strip() else ( - exc.response.reason_phrase if status is not None else - "Request timed out" if isinstance(exc, APITimeoutError) else "Could not connect to the API" - ) + 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 @@ -61,11 +64,11 @@ def clean(value): elif status == 404 and "devices" in request.url.path.split("/"): hint = "Find device ids with `mobilerun device list -t cloud`." elif status is None: - url = urlsplit(ctx.params.get("api_url") or os.environ.get("MOBILERUN_API_BASE_URL") or DEFAULT_API_URL) - safe_url = urlunsplit((url.scheme, url.netloc.rsplit("@", 1)[-1], url.path, "", "")) - hint = f"Check the API URL: {clean(safe_url)}" + hint = f"Check the API URL: {clean(request.url.scheme + '://' + request.url.host)}" + if hint: + text += f"\n{hint}" diagnostic = f"{request.method} {clean(request.url.path)}" - return text, document, hint, diagnostic + return text, document, diagnostic class Command(click.Command): @@ -98,12 +101,12 @@ def invoke(self, ctx): except (KeyboardInterrupt, asyncio.CancelledError, click.Abort): ctx.exit(130) except Exception as exc: - converted = convert_api_error(exc, ctx) + converted = convert_api_error(exc) if converted: - text, document, hint, diagnostic = converted + text, document, diagnostic = converted if ctx.params.get("verbose"): click.echo(diagnostic, err=True) - exc = click.ClickException(text + (f"\n{hint}" if hint else "")) + exc = click.ClickException(text) else: if not isinstance(exc, click.ClickException): if ctx.params.get("verbose"): From e31d678e4bbc191d4b747e42d7620ee81d1ee86d Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 19:08:39 +0100 Subject: [PATCH 21/59] fix(cli): align ui output --- mobilerun/cli/device_commands.py | 68 +++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index fcafdcb9..e950f86e 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -4,15 +4,18 @@ import base64 import json import tempfile +import unicodedata +from collections.abc import Mapping from contextlib import contextmanager import click -from mobilerun_core._a11y import node_bounds from mobilerun.agent.utils.android_device import is_portal_http_url from mobilerun.cli.options import Group, device_options, emit, json_option, verbose_option from mobilerun.cli.selection import core_client, portal_token, select_device +UI_FLAGS = ("clickable", "checkable", "checked", "editable", "focused", "scrollable", "selected") + @contextmanager def device_driver(params=None, *, selection=None): @@ -72,30 +75,54 @@ def screenshot(json_output, **params): def _ui_cell(value): - return str(value or "").replace("\t", " ").replace("\n", " ").replace("\r", " ") + 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 _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 ("clickable", "checkable", "checked", "editable", "focused", "scrollable", "selected") - if node.get(flag) or node.get("is" + flag.capitalize()) or node.get("is_" + flag)] - if node.get("disabled") or any(node.get(key) is False for key in ("enabled", "isEnabled", "is_enabled")): + 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 = node.get("text") or node.get("contentDescription") or node.get("content_description") or "" - resource = node.get("resourceId") or node.get("resource_id") or node.get("accessibilityIdentifier") or "" + 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 = node.get("className") or node.get("class_name") or "" - bounds = node_bounds(node) or {} - if isinstance(bounds, dict): - if all(key in bounds for key in ("x", "y", "width", "height")): - bounds = (bounds["x"], bounds["y"], bounds["x"] + bounds["width"], bounds["y"] + bounds["height"]) - else: - bounds = tuple(bounds.get(key, "") for key in ("left", "top", "right", "bottom")) if bounds else () - yield "\t".join((str(index), _ui_cell(class_name.rsplit(".", 1)[-1]), _ui_cell(text), - _ui_cell(resource), ",".join(str(value) for value in bounds) if bounds else "", - ",".join(flags))) + 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() @@ -104,11 +131,16 @@ def ui_lines(state, all_elements=False): @device_options 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(filter=False) + 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 {} From bf264a0dc58ad11016aa9cd58b9fde024bb4c475 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 19:08:39 +0100 Subject: [PATCH 22/59] test(cli): cover polish regressions --- tests/test_cli_polish_fix2.py | 299 +++++++++++++++++++++++++--------- 1 file changed, 225 insertions(+), 74 deletions(-) diff --git a/tests/test_cli_polish_fix2.py b/tests/test_cli_polish_fix2.py index 706654bb..0b2247f7 100644 --- a/tests/test_cli_polish_fix2.py +++ b/tests/test_cli_polish_fix2.py @@ -2,71 +2,136 @@ import json from types import SimpleNamespace -from unittest.mock import Mock +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, APITimeoutError +from mobilerun_sdk import APIConnectionError, APIStatusError, Mobilerun from mobilerun.cli import device_lifecycle, 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 PORTAL_TOKEN_ENV_VARS, DeviceConfig +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 (*PORTAL_TOKEN_ENV_VARS.values(), "MOBILERUN_CONFIG", "MOBILERUN_DEVICE", "MOBILERUN_TARGET"): + 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", ["android", "ios"]) -def test_platform_tokens_override_config_without_crossing_platforms(monkeypatch, platform): +@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") - monkeypatch.setenv(PORTAL_TOKEN_ENV_VARS[other], "other-platform-token") assert config.resolve_auth_token() == "config-token" - monkeypatch.setenv(PORTAL_TOKEN_ENV_VARS[platform], "selected-platform-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" - selected = selection.Selection(MobileConfig(device=config), "http://localhost:9", other, f"local-{other}-http") 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): - monkeypatch.setenv("MOBILERUN_" + "DEVICE_TOKEN", "ignored-token") + # 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): - for name in PORTAL_TOKEN_ENV_VARS.values(): + 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 PORTAL_TOKEN_ENV_VARS.values()) + 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 PORTAL_TOKEN_ENV_VARS.values()) + assert all(rows[name]["value"] == "set" and rows[name]["source"] == "env" + for name in names) -def test_env_uses_config_token_only_for_configured_platform(): - config = MobileConfig(device=DeviceConfig(platform="ios", auth_token="config-secret-marker")) +@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[PORTAL_TOKEN_ENV_VARS["ios"]]["value"] == "set" - assert rows[PORTAL_TOKEN_ENV_VARS["ios"]]["source"] == "config" - assert rows[PORTAL_TOKEN_ENV_VARS["android"]]["value"] == "not set" + 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(run_module, "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", @@ -93,6 +158,14 @@ def test_env_uses_config_token_only_for_configured_platform(): "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"]) @@ -107,14 +180,10 @@ def test_ui_text_is_one_line_per_core_node(monkeypatch, device_id, all_elements) 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_LINES[:] - if all_elements: - expected.insert(0, "0\tFrameLayout\t\t\t0,0,100,200\t") - expected.insert(2, "2\tView\t\t\t\t") + 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.assert_called_once_with(filter=False) device.ui_json.assert_not_called() @@ -124,7 +193,7 @@ def test_ui_json_remains_raw_core_state(monkeypatch): 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", "--all"]) + 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 == "" @@ -132,58 +201,140 @@ def test_ui_json_remains_raw_core_state(monkeypatch): device.ui.assert_not_called() -@pytest.mark.parametrize("kind,status,body,message,code,hint", [ - ("status", 401, {"detail": "Not authorized", "title": "Unauthorized", "code": "unauthorized"}, "Not authorized", "unauthorized", "mobilerun auth login"), - ("status", 403, {"title": "Forbidden"}, "Forbidden", None, "MOBILERUN_CLOUD_API_KEY"), - ("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), - ("connection", None, None, "Could not connect to the API", None, "https://api.example.test/v1"), - ("timeout", None, None, "Request timed out", None, "https://api.example.test/v1"), +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), ]) -@pytest.mark.parametrize("json_output", [False, True]) -@pytest.mark.parametrize("wrapped", [False, True]) -def test_api_errors_are_safe_and_consistent(monkeypatch, kind, status, body, message, code, hint, json_output, wrapped): - marker = "private-key-marker" - header_marker = "private-header-marker" - request = httpx.Request("GET", f"https://api.example.test/v1/devices/missing?token={marker}", - headers={"Authorization": f"Bearer {marker}", "X-Private": header_marker}) - if status is not None: - body = {**body, "raw_secret": marker, "request_headers": dict(request.headers)} - error = APIStatusError(f"raw body {body!r}", response=httpx.Response(status, request=request), body=body) - elif kind == "timeout": - error = APITimeoutError(request) - else: - error = APIConnectionError(message=f"raw connection {marker} {header_marker}", request=request) - def fail(**kwargs): - if wrapped: - raise RuntimeError(f"core wrapper {marker}") from error - raise error - monkeypatch.setattr(device_lifecycle, "core_client", lambda *a, **kw: SimpleNamespace(list_devices=fail)) - monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", marker) - flags = ["-j"] if json_output else [] - result = CliRunner().invoke(cli, ["device", "list", "-t", "cloud", "--api-url", "https://api.example.test/v1", "-v", *flags]) +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, "MOBILERUN_CLOUD_API_KEY"), + ("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(device_lifecycle, "core_client", lambda *a, **kw: SimpleNamespace( + list_devices=Mock(side_effect=error), + )) + result = CliRunner().invoke(cli, ["device", "list", "-t", "cloud", "-v", *flags]) assert result.exit_code == 1, result.output - assert marker not in result.output and header_marker not in result.output - assert "Authorization" not in result.output and "request_headers" not in result.output - assert "raw body" not in result.output and "core wrapper" not in result.output - assert "Traceback" not in result.output and "?token" not in result.output - assert "GET /v1/devices/missing\n" in result.stderr - assert f"Error: {message}" + (f" (HTTP {status})" if status else "") in result.stderr - if hint: - assert hint in result.stderr - if json_output: - assert json.loads(result.stdout) == {"error": message, "status": status, "code": code} - else: + 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_error_message_redacts_echoed_key(monkeypatch): - marker = "echoed-private-key" - request = httpx.Request("GET", "https://api.example.test/v1/devices", headers={"Authorization": f"Bearer {marker}"}) - body = {"detail": f"Invalid credential {marker}"} - error = APIStatusError("do not print raw body", response=httpx.Response(401, request=request), body=body) - monkeypatch.setattr(device_lifecycle, "core_client", lambda *a, **kw: SimpleNamespace(list_devices=Mock(side_effect=error))) - result = CliRunner().invoke(cli, ["device", "list", "-t", "cloud", "-j"]) - assert result.exit_code == 1 - assert marker not in result.output - assert json.loads(result.stdout)["error"] == "Invalid credential [redacted]" +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") + assert json.loads(result.stdout) == {"error": message, "status": status, "code": code} + + +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} From c784f80eb640dc0e3ab8f6e8d95ed1a5ecb17e4f Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 19:10:46 +0100 Subject: [PATCH 23/59] fix(cli): prefer adb autoconnect --- README.md | 2 +- mobilerun/cli/device_pair.py | 84 ++++++++++++++----- tests/test_cli_qr.py | 156 +++++++++++++++++++++++++++++------ 3 files changed, 197 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index e2b09b22..a23f956c 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ mobilerun env ``` `mobilerun auth login` shows a sign-in QR code in the terminal; `--no-qr` suppresses it. -`mobilerun device pair` pairs and connects an Android device by scanning its QR code under Developer options → Wireless debugging → "Pair device with QR code" on the same Wi-Fi. +`mobilerun device pair` pairs and connects an Android device by scanning its QR code under Developer options → Wireless debugging → "Pair device with QR code" on the same Wi-Fi, then prints the device serial. Use `-h` or `--help` on any command, and `-V` or `--version` for the version. Read commands and `run` accept `-j`/`--json`; stdout contains one JSON document, diff --git a/mobilerun/cli/device_pair.py b/mobilerun/cli/device_pair.py index 5793d726..5eb23ce1 100644 --- a/mobilerun/cli/device_pair.py +++ b/mobilerun/cli/device_pair.py @@ -1,5 +1,6 @@ """Pair and connect a local Android device through adb wireless debugging.""" +import re import secrets import shutil import string @@ -21,10 +22,6 @@ class MdnsService(NamedTuple): service_type: str address: str - @property - def ip(self) -> str: - return self.address.rsplit(":", 1)[0] - def parse_mdns_services(output: str) -> list[MdnsService]: """Read adb's tabular services, ignoring its header and unrelated lines.""" @@ -49,6 +46,16 @@ def parse_mdns_services(output: str) -> list[MdnsService]: 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]: @@ -88,20 +95,61 @@ def _adb(*args: str, deadline: float, input: str | None = None): return result -def _wait_for_service( - service_type: str, deadline: float, *, name: str | None = None, - ip: str | None = None, -) -> MdnsService: +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 (name is None or service.name == name) - and (ip is None or service.ip == ip)): + 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() @verbose_option @click.option( @@ -137,14 +185,12 @@ def pair(timeout: int, verbose: bool | None): deadline=deadline) if "successfully paired to" not in result.stdout.lower(): raise _adb_failure(result, "adb pairing failed.") - connection = _wait_for_service( - "_adb-tls-connect._tcp", deadline, ip=pairing.ip - ) - result = _adb("connect", connection.address, deadline=deadline) - if not adb_connect_succeeded(result.stdout): - raise _adb_failure(result, "adb connection failed.") - emit(connection.address) + 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 {connection.address}`.", + f"Paired and connected. Run `mobilerun device setup -d {serial}`.", err=True, ) diff --git a/tests/test_cli_qr.py b/tests/test_cli_qr.py index 22002305..633b8d9f 100644 --- a/tests/test_cli_qr.py +++ b/tests/test_cli_qr.py @@ -164,7 +164,6 @@ def test_mdns_parser_reads_services_and_ignores_header(): ("legacy", "_adb._tcp", "192.0.2.1:5555"), ("ipv6", "_adb-tls-connect._tcp", "[2001:db8::1]:30004"), ] - assert services[-1].ip == "[2001:db8::1]" @pytest.mark.parametrize("address", [ @@ -194,9 +193,10 @@ def pairing(monkeypatch): "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, "unrelated\t_adb-tls-connect._tcp\t192.0.2.2:33001", ""), + (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, "connected to 192.0.2.1:33002", ""), + (0, "adb-device._adb-tls-connect._tcp\tdevice", ""), ] calls = [] @@ -209,17 +209,43 @@ def run(*args, **kwargs): return SimpleNamespace(responses=responses, calls=calls, printed=printed, clock=clock) -def test_pair_sequences_discovery_pair_and_connect_without_exposing_password(pairing): +@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 == "192.0.2.1:33002\n" + assert result.stdout == "adb-device._adb-tls-connect._tcp\n" assert "Paired and connected" in result.stderr - assert "mobilerun device setup -d 192.0.2.1:33002" 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"), ("mdns", "services"), ("mdns", "services"), - ("connect", "192.0.2.1:33002"), + ("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) @@ -228,30 +254,109 @@ def test_pair_sequences_discovery_pair_and_connect_without_exposing_password(pai assert pairing.clock.now == 2 -def test_pair_prefers_ipv4_when_ipv6_is_listed_first(pairing): +@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", "") - pairing.responses[5] = (0, - "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", "") result = TerminalRunner().invoke(cli, ["device", "pair"]) assert result.exit_code == 0, result.output - assert result.stdout == "192.0.2.1:33002\n" + assert result.stdout == "adb-device._adb-tls-connect._tcp\n" assert pairing.calls[3][0] == ("pair", "192.0.2.1:32001") - assert pairing.calls[-1][0] == ("connect", "192.0.2.1:33002") -def test_pair_accepts_bracketed_global_ipv6_when_no_ipv4_exists(pairing): +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", "") - pairing.responses[5] = (0, - "adb-device\t_adb-tls-connect._tcp\t[2001:db8::1]:33002", "") result = TerminalRunner().invoke(cli, ["device", "pair"]) assert result.exit_code == 0, result.output - assert result.stdout == "[2001:db8::1]:33002\n" + assert result.stdout == "adb-device._adb-tls-connect._tcp\n" assert pairing.calls[3][0] == ("pair", "[2001:db8::1]:32001") - assert pairing.calls[-1][0] == ("connect", "[2001:db8::1]:33002") CONNECT_REPLIES = [ @@ -264,8 +369,8 @@ def test_pair_accepts_bracketed_global_ipv6_when_no_ipv4_exists(pairing): @pytest.mark.parametrize("reply,exit_code", CONNECT_REPLIES) -def test_pair_connect_reply(reply, exit_code, pairing): - pairing.responses[-1] = (0, reply, "") +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 @@ -297,12 +402,13 @@ def test_pair_timeout(pairing, response_count, limit): assert pairing.clock.now == limit -@pytest.mark.parametrize("stage,index,message,expected", [ - ("pair", 3, "Failed pair: Ab12Cd34Ef", "Failed pair: [redacted]"), - ("connect", 6, "Failed connect: refused", "Failed connect: refused"), +@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(pairing, stage, index, message, expected, returncode): +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 From 41fbe8c543f78ab2b6f55e6b2a7006b583f42a75 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 19:19:38 +0100 Subject: [PATCH 24/59] docs(readme): document cli workflow --- README.md | 281 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 190 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index a23f956c..aa3f7c9c 100644 --- a/README.md +++ b/README.md @@ -72,144 +72,243 @@ 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`. - -Install Mobilerun with [`uv`](https://docs.astral.sh/uv/): +Use Python 3.11–3.13. Python 3.14 is not supported. +Install the CLI with [`uv`](https://docs.astral.sh/uv/): ```bash -# CLI usage -uv tool install mobilerun +uv tool install --python 3.13 mobilerun ``` -```bash -# CLI + Python integration -uv pip install mobilerun -``` - -Most LLM providers are included by default. For Anthropic support, install the optional extra: +For Anthropic models, include the optional extra: ```bash -uv tool install "mobilerun[anthropic]" +uv tool install --force --python 3.13 "mobilerun[anthropic]" ``` -## 🚀 Quickstart +For Python integration, install into a virtual environment instead: ```bash -uv tool install mobilerun -mobilerun device setup -mobilerun configure -mobilerun run "Open settings and turn on dark mode" +uv venv --python 3.13 +uv pip install mobilerun ``` -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. +## 🚀 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. -### 1. Install the Portal on your device +**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 setup +mobilerun device pair ``` -This installs the Mobilerun Portal app, enables its accessibility service, and prepares the device for local control. +Keep this command in a terminal. It prints the ready device serial after adb +connects. The timeout is 120 seconds; `--timeout SECONDS` changes it. -### 2. Verify the connection +**USB:** enable Developer options and USB debugging, connect the device, and approve +its debugging prompt. Check the connection: ```bash -mobilerun device ping +mobilerun device list ``` -You should see confirmation that the Portal is installed and accessible. - -### 3. Configure your LLM provider +Install the Portal, check device access, configure an LLM provider, and run a task: ```bash +mobilerun device setup +mobilerun device ping mobilerun configure +mobilerun run "Open Settings and report the Android version" ``` -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`. +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. -### 4. Run your first command +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 run "Open the settings app and tell me the Android version" +mobilerun auth login +mobilerun device list -t cloud ``` -Useful run options: +Sign-in prints a link, a user code, and a QR code when stderr is a terminal. +Scan the QR or use the link and code to approve access. This cloud sign-in is +separate from the LLM provider configured above. + +Set the target and default device, replacing the placeholder with the listed id: ```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" --platform ios -mobilerun run "Open Settings" --max-steps 30 --verbose +export MOBILERUN_TARGET=cloud +export MOBILERUN_DEVICE="CLOUD_DEVICE_ID" +mobilerun run "Open Settings and report the Android version" -d "$MOBILERUN_DEVICE" ``` -### CLI commands and environment +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" -mobilerun device list|connect|disconnect|setup|ping -mobilerun device tap|swipe|long-press|type|press|screenshot|ui|apps|start -mobilerun auth login [SERVICE] -mobilerun auth logout [SERVICE] -mobilerun auth status -mobilerun configure -mobilerun macro replay|list -mobilerun doctor -mobilerun env +mobilerun +├── run TASK +├── device +│ ├── list +│ ├── pair +│ ├── connect SERIAL +│ ├── disconnect SERIAL +│ ├── setup +│ ├── ping +│ ├── tap X Y +│ ├── swipe X1 Y1 X2 Y2 +│ ├── long-press X Y +│ ├── type TEXT +│ ├── press BUTTON +│ ├── screenshot +│ ├── ui +│ ├── apps +│ └── start PACKAGE +├── auth +│ ├── login [SERVICE] +│ ├── logout [SERVICE] +│ └── status +├── configure +├── macro +│ ├── replay PATH +│ └── list [DIRECTORY] +├── doctor +└── env ``` -`mobilerun auth login` shows a sign-in QR code in the terminal; `--no-qr` suppresses it. -`mobilerun device pair` pairs and connects an Android device by scanning its QR code under Developer options → Wireless debugging → "Pair device with QR code" on the same Wi-Fi, then prints the device serial. +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 provider choices in help include `openrouter` and +`deepseek` for `run` only. See each command's help for its arguments and options. + +### Shared flags -Use `-h` or `--help` on any command, and `-V` or `--version` for the version. -Read commands and `run` accept `-j`/`--json`; stdout contains one JSON document, -while progress and errors go to stderr. Failures exit 1, invalid usage exits 2, -and interruption exits 130. +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. -Device commands, `run`, `doctor`, and macro replay share `-d`/`--device`, -`-t`/`--target local|cloud`, `-P`/`--platform android|ios`, `-c`/`--config`, -`-v`/`--verbose`, `--api-url`. `run` and macro replay also accept `--tcp`/`--no-tcp`. An explicit device -selects its backend: an ADB serial, a Portal HTTP URL, or a cloud device id. -Without a device, local commands auto-select; cloud commands require an id. +| 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. | +| — | `--no-qr` | Mobilerun login: suppress the QR code. | + +### Environment + +For CLI options, flags override environment values, then config values and defaults. +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. + +| 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_CLOUD_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 terminal styling where supported | 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. +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 auth login -mobilerun auth status -j -mobilerun device list -t cloud -j -mobilerun device ui -d "$DEVICE_ID" -j -mobilerun run "Report the current app" -d "$DEVICE_ID" -j -mobilerun auth login openai --no-browser -mobilerun configure -p openai -m gpt-6.1-sol -mobilerun run "Open settings" -p ollama -m llama3.2 -u http://localhost:11434 -s 20 -mobilerun macro replay trajectories/example -s 5 -n -mobilerun env -j +mobilerun device ui | grep -i 'settings' +mobilerun device ui -j | jq '.a11y_tree' +mobilerun device list -t cloud -j | jq '.[].device_id' ``` -`auth login` and `auth logout` default to `mobilerun`; provider services are -`openai`, `anthropic`, `gemini`, and `xai`. The configure wizard and `run -p` -share these provider names: `openai`, `openai-oauth`, `anthropic`, -`anthropic-oauth`, `gemini`, `gemini-oauth`, `xai`, `ollama`, `openai-like`, -`minimax`, and `zai` (case-insensitive). `-u`/`--llm-url` sets the LLM endpoint; -`--api-url` sets the cloud device API endpoint. +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. -| Variable | Meaning | Default / flag | -| --- | --- | --- | -| `MOBILERUN_TARGET` | Where to discover devices | `local`; `-t`/`--target` | -| `MOBILERUN_DEVICE` | Serial, Portal URL, or cloud device id | Auto-select; `-d`/`--device` | -| `MOBILERUN_CLOUD_API_KEY` | Cloud credential | Saved `auth login` credential | -| `MOBILERUN_API_BASE_URL` | Cloud API URL | `https://api.mobilerun.ai/v1`; `--api-url` | -| `MOBILERUN_AUTH_URL` | Sign-in URL | `https://cloud.mobilerun.ai/api/auth`; `--auth-url` | -| `MOBILERUN_CONFIG` | Config file | User config directory; `-c`/`--config` | -| `MOBILERUN_ANDROID_PORTAL_TOKEN` | Android Portal HTTP token | Configured token, if any | -| `MOBILERUN_IOS_PORTAL_TOKEN` | iOS Portal HTTP token | Configured token, if any | -| `MOBILERUN_DEBUG` | Verbose logs | Off; `-v`/`--verbose` | -| `MOBILERUN_TELEMETRY_ENABLED` | Anonymous telemetry | `true`, subject to config policy | - -Flags override environment values, then config values and defaults. `mobilerun env` -shows resolved values and their sources; secret values appear only as `set` or -`not set`. Standard provider keys, `ANDROID_SERIAL`, `NO_COLOR`, `PHOENIX_URL`, -and `PHOENIX_PROJECT_NAME` remain supported. `MOBILERUN_STREAM_SCREENSHOTS` and -`MOBILERUN_OAUTH_MANUAL` control screenshot streaming and manual sign-in. +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":"Device not found","status":404,"code":null} +``` + +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. + +### 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). From b942b61d65624c38ba3e6d4f4a8d853abea0d35f Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 20:10:36 +0100 Subject: [PATCH 25/59] feat(cli): improve help discovery --- mobilerun/cli/auth.py | 24 +++--- mobilerun/cli/configure.py | 9 ++- mobilerun/cli/device_commands.py | 11 ++- mobilerun/cli/device_lifecycle.py | 8 +- mobilerun/cli/device_pair.py | 4 +- mobilerun/cli/main.py | 6 +- mobilerun/cli/options.py | 58 +++++++++++++-- mobilerun/cli/providers.py | 12 ++- mobilerun/cli/run.py | 43 ++++++++--- mobilerun/macro/cli.py | 10 ++- tests/test_cli_contract.py | 2 +- tests/test_cli_devx.py | 118 ++++++++++++++++++++++++++++++ 12 files changed, 258 insertions(+), 47 deletions(-) create mode 100644 tests/test_cli_devx.py diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py index 5b35025f..dd46b5c8 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -160,7 +160,7 @@ def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool): } service_argument = click.argument( - "service", type=click.Choice(["mobilerun", *SERVICES]), default="mobilerun" + "service", type=click.Choice(["mobilerun", *SERVICES]), default="mobilerun", metavar="[SERVICE]" ) @@ -170,27 +170,29 @@ def auth_cli(verbose): """Sign in, sign out, and inspect saved credentials.""" -@auth_cli.command() +@auth_cli.command( + epilog='\b\nExamples:\n mobilerun auth login\n mobilerun auth login openai --no-browser', +) @service_argument @verbose_option @auth_url_option -@model_option +@model_option(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." + "--credential-path", type=click.Path(dir_okay=False), help="Provider credential file (openai, anthropic, gemini, xai)." ) @click.option( "--timeout", type=float, - default=None, + default=300.0, callback=_validate_oauth_timeout, - help="Login timeout in seconds (default 300).", + help="Provider login timeout in seconds (openai, anthropic, gemini, xai).", ) -@click.option("--no-browser", is_flag=True, help="Do not open a browser.") +@click.option("--no-browser", is_flag=True, help="Do not open a provider login browser (openai, anthropic, gemini, xai).") @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.") -@click.option("--callback-port", type=click.IntRange(0, 65535), help="OAuth callback port.") -@click.option("--callback-path", help="OAuth callback path.") +@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.") @click.option("--token", help="Save an Anthropic setup-token.") @click.pass_context @@ -211,7 +213,7 @@ def login( device_code, token, ): - """Log in to SERVICE (default mobilerun).""" + """Log in to a service; SERVICE defaults to mobilerun.""" for name in set().union(*LOGIN_OPTIONS.values()): if ( ctx.get_parameter_source(name) == click.core.ParameterSource.COMMANDLINE diff --git a/mobilerun/cli/configure.py b/mobilerun/cli/configure.py index 4fed057f..c5004fa4 100644 --- a/mobilerun/cli/configure.py +++ b/mobilerun/cli/configure.py @@ -18,11 +18,14 @@ model_option, verbose_option, ) -from mobilerun.cli.providers import PROVIDERS, provider_option +from mobilerun.cli.providers import PROVIDERS, configure_provider_option -@click.command(cls=Command, context_settings=CONTEXT_SETTINGS) -@provider_option +@click.command( + cls=Command, context_settings=CONTEXT_SETTINGS, + epilog='\b\nExamples:\n mobilerun configure\n mobilerun configure -p openai -m MODEL', +) +@configure_provider_option @model_option @llm_url_option @verbose_option diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index e950f86e..a64ad206 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -51,10 +51,13 @@ def probe_device(params, *, selection=None): device.ui() -@click.group(cls=Group) +@click.group( + cls=Group, + epilog='\b\nExamples:\n mobilerun device list\n mobilerun device pair\n mobilerun device ui -d DEVICE', +) @verbose_option def device_cli(verbose): - """Direct device actions (screenshot, tap, swipe, etc.).""" + """Discover, pair, set up, inspect, and control devices.""" @device_cli.command() @@ -125,7 +128,9 @@ def ui_lines(state, all_elements=False): )) -@device_cli.command() +@device_cli.command( + epilog='\b\nExamples:\n mobilerun device ui -d DEVICE\n mobilerun device ui -d DEVICE -j', +) @json_option @click.option("--all", "all_elements", is_flag=True, help="Include empty layout containers.") @device_options diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index 48c8948e..05ce19e8 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -20,7 +20,9 @@ from mobilerun.cli.setup import _setup_portal -@device_cli.command("list") +@device_cli.command( + "list", epilog='\b\nExamples:\n mobilerun device list\n mobilerun device list --all -j', +) @target_option @api_url_option @verbose_option @@ -59,7 +61,7 @@ def list_devices(target, api_url, verbose, json_output, all_devices): @device_cli.command() -@click.argument("serial") +@click.argument("serial", metavar="HOST:PORT") @verbose_option @coro async def connect(serial, verbose): @@ -71,7 +73,7 @@ async def connect(serial, verbose): @device_cli.command() -@click.argument("serial") +@click.argument("serial", metavar="HOST:PORT") @verbose_option @coro async def disconnect(serial, verbose): diff --git a/mobilerun/cli/device_pair.py b/mobilerun/cli/device_pair.py index 5eb23ce1..22fb3326 100644 --- a/mobilerun/cli/device_pair.py +++ b/mobilerun/cli/device_pair.py @@ -150,7 +150,9 @@ def _wait_for_transport(guid: str, deadline: float) -> str: time.sleep(1) -@device_cli.command() +@device_cli.command( + epilog='\b\nExamples:\n mobilerun device pair\n mobilerun device pair --timeout 180', +) @verbose_option @click.option( "--timeout", type=click.IntRange(min=1), default=120, show_default=True, diff --git a/mobilerun/cli/main.py b/mobilerun/cli/main.py index 98ad2e81..48fa5c37 100644 --- a/mobilerun/cli/main.py +++ b/mobilerun/cli/main.py @@ -12,7 +12,11 @@ __all__ = ["cli", "run_command"] -@click.group(cls=Group) +@click.group( + cls=Group, + command_order=["run", "device", "configure", "auth", "doctor", "env", "macro", "completion"], + epilog='\b\nExamples:\n mobilerun device list\n mobilerun configure -p openai\n mobilerun run "Open Settings"\n\nDocs: https://docs.mobilerun.ai/framework/guides/cli', +) @click.version_option(version("mobilerun"), "-V", "--version") @verbose_option def cli(verbose): diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index e8bd6804..fdba0375 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -1,6 +1,7 @@ """Shared Click options and command IO policy.""" import asyncio +import difflib import json import os import sys @@ -71,7 +72,28 @@ def clean(value): return text, document, diagnostic -class Command(click.Command): +class HelpLayout: + """Keep shared flags and complete command summaries consistent.""" + + def __init__(self, *args, **kwargs): + kwargs.setdefault("context_settings", CONTEXT_SETTINGS) + super().__init__(*args, **kwargs) + for param in self.params: + if isinstance(param, click.Option) and param.show_default is None: + param.show_default = True + + def get_params(self, ctx): + first = {"device": 0, "target": 1, "platform": 2, "config_path": 3} + last = {"api_url": 5, "json_output": 6, "verbose": 7, "help": 8} + return sorted(super().get_params(ctx), key=lambda param: first.get( + param.name, last.get(param.name, 4) + )) + + def get_short_help_str(self, limit=45): + return self.short_help or " ".join((self.help or "").split("\n\n", 1)[0].split()) + + +class Command(HelpLayout, click.Command): def invoke(self, ctx): ctx.ensure_object(dict) ctx.obj["stdout"] = sys.stdout @@ -122,14 +144,32 @@ def invoke(self, ctx): logger.setLevel(level) -class Group(click.Group): +class Group(HelpLayout, click.Group): command_class = Command group_class = type - def __init__(self, *args, **kwargs): - kwargs.setdefault("context_settings", CONTEXT_SETTINGS) + 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 args and exc.message.startswith("No such command"): + matches = difflib.get_close_matches(args[0], self.list_commands(ctx), n=1, cutoff=0.6) + if matches: + exc.message += f" Did you mean '{matches[0]}'?" + raise + # The same declarations are reused everywhere, including groups. verbose_option = click.option( @@ -184,16 +224,20 @@ def __init__(self, *args, **kwargs): ) auth_url_option = click.option( "--auth-url", - default=None, + default="https://cloud.mobilerun.ai/api/auth", envvar="MOBILERUN_AUTH_URL", show_envvar=True, - help="Sign-in URL (default https://cloud.mobilerun.ai/api/auth).", + 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.") +def model_option(function=None, *, help="LLM model."): + decorator = click.option("-m", "--model", help=help) + return decorator(function) if function is not None else decorator + + 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.") diff --git a/mobilerun/cli/providers.py b/mobilerun/cli/providers.py index 37ccdc23..20596f3a 100644 --- a/mobilerun/cli/providers.py +++ b/mobilerun/cli/providers.py @@ -34,9 +34,15 @@ def runtime(self): "openrouter": Provider(None, "api_key", "api_base", "OpenRouter"), "deepseek": Provider(None, "api_key", "base_url", "DeepSeek"), } -provider_option = click.option( - "-p", "--provider", type=click.Choice(list(PROVIDERS), case_sensitive=False), help="LLM provider.", -) +def _provider_option(names): + return click.option( + "-p", "--provider", type=click.Choice(names, case_sensitive=False), metavar="PROVIDER", + help="LLM provider: " + ", ".join(names) + ".", + ) + + +provider_option = _provider_option(list(PROVIDERS)) +configure_provider_option = _provider_option([name for name, spec in PROVIDERS.items() if spec.family]) def llm_url_kwargs(runtime, url): diff --git a/mobilerun/cli/run.py b/mobilerun/cli/run.py index 96805f83..f0411bed 100644 --- a/mobilerun/cli/run.py +++ b/mobilerun/cli/run.py @@ -286,7 +286,30 @@ async def _cleanup_android_keyboard(config: MobileConfig, serial: str | None = N click.echo("Failed to disable Mobilerun keyboard", err=True) -@click.command(cls=Command, context_settings=CONTEXT_SETTINGS) +class AgentOption(click.Option): + def get_help_record(self, ctx): + names = list_agents() + if not names: + return None + self.type = click.Choice(names) + return super().get_help_record(ctx) + + def type_cast_value(self, ctx, value): + if value is not None: + names = list_agents() + if not names: + raise click.BadParameter( + "No external agents are installed; install one before using --agent.", + ctx=ctx, param=self, + ) + self.type = click.Choice(names) + return super().type_cast_value(ctx, value) + + +@click.command( + cls=Command, context_settings=CONTEXT_SETTINGS, + epilog='\b\nExamples:\n mobilerun run "Open Settings"\n mobilerun run "Open Settings" -d DEVICE -p openai -m MODEL', +) @click.argument("task") @device_options @tcp_option @@ -295,16 +318,16 @@ async def _cleanup_android_keyboard(config: MobileConfig, serial: str | None = N @model_option @llm_url_option @max_steps_option -@click.option("-T", "--temperature", type=float) -@click.option("-a", "--agent", type=click.Choice(list_agents()), help="External agent.") -@click.option("-r", "--reasoning/--no-reasoning", default=None) -@click.option("--vision/--no-vision", default=None) -@click.option("--vision-only", is_flag=True, default=None) -@click.option("--stream/--no-stream", default=None) -@click.option("--tracing/--no-tracing", default=None) -@click.option("--backend", type=click.Choice(["visual-remote"])) +@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"])) +@click.option("--save-trajectory", type=click.Choice(["none", "step", "action"]), help="Choose which trajectory data to save.") @coro async def run( task, diff --git a/mobilerun/macro/cli.py b/mobilerun/macro/cli.py index c2a599e1..79446257 100644 --- a/mobilerun/macro/cli.py +++ b/mobilerun/macro/cli.py @@ -56,19 +56,21 @@ def macro_cli(verbose): pass -@macro_cli.command() +@macro_cli.command( + epilog='\b\nExamples:\n mobilerun macro replay MACRO.json -n\n mobilerun macro replay TRAJECTORY -d DEVICE', +) @click.argument("path", type=click.Path(exists=True)) @device_options @tcp_option @click.option( - "--delay", help="Delay between actions (seconds)", default=1.0, type=float + "--delay", help="Delay between actions in seconds.", default=1.0, type=float ) @click.option( - "--start-from", help="Start from step number (1-based)", default=1, type=int + "--start-from", help="Start from step number (1-based).", default=1, type=int ) @max_steps_option @click.option( - "-n", "--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", diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index 210f7991..d4b9c868 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -413,7 +413,7 @@ def test_run_only_providers_work_and_configure_rejects_them(monkeypatch, provide assert load.call_args.args == (PROVIDERS[provider].runtime,) result = CliRunner().invoke(cli, ["configure", "-p", provider]) assert result.exit_code == 2 - assert "run only" in result.output + assert "Invalid value" in result.output @pytest.mark.parametrize("args,verb,expected_args,expected_kwargs", [ diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py new file mode 100644 index 00000000..fa3a7194 --- /dev/null +++ b/tests/test_cli_devx.py @@ -0,0 +1,118 @@ +"""User-facing help, output, and automation contracts for the CLI.""" + +import importlib +from unittest.mock import AsyncMock, Mock + +import click +import pytest +from click.testing import CliRunner + +from mobilerun.cli.main import cli +from mobilerun.config_manager import ConfigLoader + +run_module = importlib.import_module("mobilerun.cli.run") + + +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.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"] + assert [result.stdout.index(f" {name} ") for name in names] == sorted( + result.stdout.index(f" {name} ") for name in names + ) + + +def test_agent_option_tracks_installed_agents(monkeypatch): + monkeypatch.setattr(run_module, "list_agents", lambda: []) + runner = CliRunner() + assert "--agent" not in runner.invoke(cli, ["run", "--help"]).stdout + result = runner.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 + monkeypatch.setattr(run_module, "list_agents", lambda: ["installed"]) + 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" + assert runner.invoke(cli, ["run", "TASK", "-a", "missing"]).exit_code == 2 + + +@pytest.mark.parametrize("args,suggestion", [(["devcie"], "device"), (["device", "lst"], "list"), + (["auth", "stauts"], "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_and_configure_only_lists_supported(path): + result = CliRunner().invoke(cli, [*path, "--help"]) + assert "-p, --provider PROVIDER" in result.stdout + assert max(map(len, result.stdout.splitlines())) < 100 + if path == ("configure",): + assert "openrouter" not in result.stdout and "deepseek" not in result.stdout + for name in ("openrouter", "deepseek"): + assert CliRunner().invoke(cli, ["configure", "-p", name]).exit_code == 2 + + +def test_login_and_connection_usage(): + runner = CliRunner() + assert "[SERVICE]" in runner.invoke(cli, ["auth", "login", "--help"]).stdout + for command in ("connect", "disconnect"): + assert "HOST:PORT" in runner.invoke(cli, ["device", command, "--help"]).stdout + text = runner.invoke(cli, ["auth", "login", "--help"]).stdout + assert "[default: 300.0]" in text + assert "(openai, anthropic, gemini)" in " ".join(text.split()) From f5366b8fad72251d8ca1fc7e61604a1bdc574ee6 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 20:14:21 +0100 Subject: [PATCH 26/59] fix(cli): preserve useful output --- mobilerun/cli/device_commands.py | 18 ++- mobilerun/cli/device_lifecycle.py | 38 ++++-- mobilerun/cli/doctor_command.py | 38 ++++-- mobilerun/cli/env.py | 12 +- mobilerun/cli/options.py | 42 +++++- mobilerun/macro/cli.py | 16 +-- tests/test_cli_contract.py | 16 ++- tests/test_cli_devx.py | 205 ++++++++++++++++++++++++++++++ 8 files changed, 342 insertions(+), 43 deletions(-) diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index a64ad206..f3ffb934 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -11,7 +11,14 @@ import click from mobilerun.agent.utils.android_device import is_portal_http_url -from mobilerun.cli.options import Group, device_options, emit, json_option, verbose_option +from mobilerun.cli.options import ( + Group, + device_options, + emit, + emit_rows, + json_option, + verbose_option, +) from mobilerun.cli.selection import core_client, portal_token, select_device UI_FLAGS = ("clickable", "checkable", "checked", "editable", "focused", "scrollable", "selected") @@ -222,7 +229,14 @@ def apps(system, json_output, **params): """List installed apps.""" with device_driver(params) as device: result = device.list_apps(include_system_apps=system) - emit(result if json_output else json.dumps(result, indent=2), json_output=json_output) + 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 + )) @device_cli.command() diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index 05ce19e8..1d6065fc 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -6,11 +6,14 @@ 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_LOCAL_DEVICES_HINT, api_url_option, config_option, + convert_api_error, coro, device_option, emit, + emit_rows, json_option, platform_option, target_option, @@ -30,8 +33,31 @@ @click.option("--all", "all_devices", is_flag=True, help="List local and cloud devices.") def list_devices(target, api_url, verbose, json_output, all_devices): """List devices in the selected target.""" - scope = "all" if all_devices else target - client = core_client(api_url, cloud=scope in {"cloud", "all"}) + rows, errors = [], [] + for scope in (["local", "cloud"] if all_devices else [target]): + try: + rows.extend(_list_target(api_url, scope)) + except Exception as exc: + if not all_devices: + raise + converted = convert_api_error(exc) + message = converted[0] if converted else str(exc) + 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 + )) + else: + click.echo("No devices found. " + NO_LOCAL_DEVICES_HINT, err=True) + if len(errors) == 2: + click.get_current_context().exit(1) + + +def _list_target(api_url, scope): + client = core_client(api_url, cloud=scope == "cloud") devices = [] try: devices = client.list_devices(filters={"state": None}, scope=scope) @@ -46,13 +72,7 @@ def list_devices(target, api_url, verbose, json_output, all_devices): "state": metadata.state if metadata else "connected", "type": metadata.type if metadata else device.capabilities["backend"], }) - if json_output: - emit(rows, json_output=True) - elif rows: - for row in rows: - emit(f"{row['device_id']} {row['name'] or '-'} {row['state']} {row['type']}") - else: - click.echo("No devices found.", err=True) + return rows finally: for resource in [*devices, client]: close = getattr(resource, "close", None) diff --git a/mobilerun/cli/doctor_command.py b/mobilerun/cli/doctor_command.py index 08f84fe5..ab430059 100644 --- a/mobilerun/cli/doctor_command.py +++ b/mobilerun/cli/doctor_command.py @@ -1,11 +1,17 @@ """Click wiring for health checks.""" -from dataclasses import asdict - import click -from mobilerun.cli.doctor import Status, run_doctor -from mobilerun.cli.options import CONTEXT_SETTINGS, Command, coro, device_options, emit, json_option +from mobilerun.cli.doctor import Status, console, run_doctor +from mobilerun.cli.options import ( + CONTEXT_SETTINGS, + Command, + coro, + device_options, + emit, + json_option, + local_device_hint, +) @click.command(cls=Command, context_settings=CONTEXT_SETTINGS) @@ -14,13 +20,21 @@ @coro async def doctor(device, config_path, platform, target, api_url, verbose, json_output): """Check system and device health; fail if any check fails.""" - results = await run_doctor(device, bool(verbose), config_path, - target=target, platform=platform, api_url=api_url) + # The check runner renders progress and a summary for Python callers. + # The CLI emits its own single text/JSON result instead. + with console.capture(): + results = await run_doctor(device, bool(verbose), config_path, + target=target, platform=platform, api_url=api_url) failed = any(result.status == Status.FAIL for result in results) - if not json_output: - for result in results: - emit(f"{result.name}: {result.status.value}: {result.message}") - if failed: - raise click.ClickException("Health checks failed") + checks = [ + {"name": result.name, "status": result.status.value, + "detail": local_device_hint("; ".join(filter(None, (result.message, result.detail))))} + for result in results + ] if json_output: - emit([{**asdict(result), "status": result.status.value} for result in results], json_output=True) + 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 index 00e0a5dd..15a46f38 100644 --- a/mobilerun/cli/env.py +++ b/mobilerun/cli/env.py @@ -96,4 +96,14 @@ def env(config_path, verbose, json_output): emit(rows, json_output=True) else: for row in rows: - emit(f"{row['variable']}={row['value']} ({row['source']})") + value = row["value"] + if isinstance(value, str) and row["variable"] in { + "MOBILERUN_STREAM_SCREENSHOTS", "MOBILERUN_OAUTH_MANUAL", + }: + value = value.lower() in {"1", "true", "yes", "on"} + if isinstance(value, bool): + value = str(value).lower() + elif value is None: + value = "" + source = f" ({row['source']})" if row["source"] != value else "" + emit(f"{row['variable']}={value}{source}") diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index fdba0375..8749843b 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -14,6 +14,22 @@ DEFAULT_API_URL = "https://api.mobilerun.ai/v1" CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} +NO_LOCAL_DEVICES_HINT = ( + "Connect a device over USB or run `mobilerun device pair`; " + "use `-t cloud` for cloud devices." +) + + +def local_device_hint(message): + """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_LOCAL_DEVICES_HINT + if "unknown device" in lowered or ("device" in lowered and "not found" in lowered): + return message + "\nFind connected devices with `mobilerun device list`." + return message def _sdk_error(exc): @@ -65,7 +81,13 @@ def clean(value): elif status == 404 and "devices" in request.url.path.split("/"): hint = "Find device ids with `mobilerun device list -t cloud`." elif status is None: - hint = f"Check the API URL: {clean(request.url.scheme + '://' + request.url.host)}" + 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)}" @@ -122,6 +144,8 @@ def invoke(self, ctx): return super().invoke(ctx) except (KeyboardInterrupt, asyncio.CancelledError, click.Abort): ctx.exit(130) + except click.exceptions.Exit: + raise except Exception as exc: converted = convert_api_error(exc) if converted: @@ -134,6 +158,7 @@ def invoke(self, ctx): if ctx.params.get("verbose"): traceback.print_exc(file=sys.stderr) exc = click.ClickException(str(exc) or type(exc).__name__) + exc.message = local_device_hint(exc.message) document = {"error": exc.message} if (not isinstance(exc, click.UsageError) and ctx.params.get("json_output") and not ctx.obj["emitted"]): @@ -270,3 +295,18 @@ def emit(value, *, json_output=False): 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 emit_rows(headers, rows): + """Print tab-separated data, adding a header only on terminal stdout.""" + ctx = click.get_current_context(silent=True) + output = ctx.obj.get("stdout") if ctx and ctx.obj else sys.stdout + + def line(values): + return "\t".join(str(value if value is not None else "").replace("\t", " ") + .replace("\r", " ").replace("\n", " ") for value in values) + + if output.isatty(): + emit(line(headers)) + for row in rows: + emit(line(row)) diff --git a/mobilerun/macro/cli.py b/mobilerun/macro/cli.py index 79446257..da6ea434 100644 --- a/mobilerun/macro/cli.py +++ b/mobilerun/macro/cli.py @@ -15,6 +15,7 @@ coro, device_options, emit, + emit_rows, json_option, max_steps_option, model_option, @@ -320,20 +321,7 @@ def list(directory: str, verbose: bool, json_output: bool): 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 - emit(console_render(table)) + emit_rows(("FOLDER", "DESCRIPTION", "ACTIONS"), sorted(folders)) logger.info( f"💡 Use 'mobilerun macro replay {resolved_dir}/' to replay a trajectory" ) diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index d4b9c868..92f1f72a 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -192,7 +192,9 @@ def test_doctor_failure_json(monkeypatch): monkeypatch.setattr(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) == {"error": "Health checks failed"} + 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"), @@ -370,17 +372,23 @@ def test_cloud_list_includes_every_state_and_metadata(monkeypatch, scope): rows = [SimpleNamespace(device_id=f"id-{state}", _conn=SimpleNamespace( _device_cache=SimpleNamespace(name=f"device-{state}", state=state, type="android"))) for state in ("ready", "stopped", "provisioning")] - list_call = Mock(return_value=rows) + 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 list_call.call_args.kwargs == {"filters": {"state": None}, "scope": scope} + 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} device-{state} {state} android" in text.stdout for state in ("ready", "stopped", "provisioning")) + 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"}), diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index fa3a7194..25daa8d4 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -116,3 +116,208 @@ def test_login_and_connection_usage(): 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("failed", [False, True]) +@pytest.mark.parametrize("json_output", [False, True]) +def test_doctor_preserves_checks_once(monkeypatch, failed, json_output): + import json + + doctor = importlib.import_module("mobilerun.cli.doctor_command") + from mobilerun.cli.doctor import CheckResult, Status, console + + async def check(*args, **kwargs): + console.print("Device: duplicate progress") + console.print("Device: duplicate summary") + return [CheckResult("Device", Status.FAIL if failed else Status.PASS, "observed", "detail")] + + monkeypatch.setattr(doctor, "run_doctor", check) + result = CliRunner().invoke(cli, ["doctor", *(["-j"] if json_output else [])]) + assert result.exit_code == int(failed), result.output + assert "duplicate" not in result.output + 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): + from mobilerun.cli import device_lifecycle + + 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): + from io import StringIO + + from mobilerun.cli.options import emit_rows + + 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"), ("last", None)]) + expected = "first\tline break column\nlast\t\n" + assert output.getvalue() == ("NAME\tDETAIL\n" if tty else "") + expected + + +@pytest.mark.parametrize("local_failure,cloud_failure", [(False, False), (False, True), + (True, False), (True, True)]) +@pytest.mark.parametrize("json_output", [False, True]) +def test_all_devices_retains_partial_results_and_closes(monkeypatch, local_failure, cloud_failure, json_output): + import json + from types import SimpleNamespace + + from mobilerun.cli import device_lifecycle + + 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, _conn=object(), 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) + result = CliRunner().invoke(cli, ["device", "list", "--all", *(["-j"] if json_output else [])]) + assert result.exit_code == int(local_failure and cloud_failure), result.output + assert calls == ["local", "cloud"] + assert "Error: 1" not in result.output + expected = [target for target, failed in (("local", local_failure), ("cloud", cloud_failure)) if not failed] + if json_output: + data = json.loads(result.stdout) + assert [row["device_id"] for row in data["devices"]] == expected + assert data["errors"] == [{"target": target, "error": target + " unavailable"} + for target, failed in (("local", local_failure), ("cloud", cloud_failure)) if failed] + else: + assert [line.split("\t")[0] for line in result.stdout.splitlines()] == expected + assert result.stderr.count("Warning: cloud:") == int(cloud_failure) + assert result.stderr.count("Warning: local:") == int(local_failure) + for client in clients: + client.close.assert_called_once() + for device, failed in zip(devices, (local_failure, cloud_failure), strict=True): + assert device.close.call_count == int(not failed) + + +@pytest.mark.parametrize("json_output", [False, True]) +def test_all_devices_with_no_key_and_empty_local_is_success(monkeypatch, json_output): + import json + from types import SimpleNamespace + + from mobilerun.cli import device_lifecycle, selection + + 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) + result = CliRunner().invoke(cli, ["device", "list", "--all", *(["-j"] if json_output else [])]) + assert result.exit_code == 0, result.output + assert result.stderr.count("Warning: cloud:") == 1 + if json_output: + data = json.loads(result.stdout) + assert data["devices"] == [] + assert "No cloud API key" in data["errors"][0]["error"] + else: + assert "USB" in result.stderr and "mobilerun device pair" in result.stderr and "-t cloud" in result.stderr + + +def test_connection_hint_preserves_port_and_hides_url_secrets(): + import httpx + from mobilerun_sdk import APIConnectionError + + from mobilerun.cli.options import convert_api_error + + 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): + from contextlib import contextmanager + from types import SimpleNamespace + + from mobilerun.cli import device_commands + + @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): + import json + + from mobilerun.macro.state import MACRO_SCHEMA_VERSION + + 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): + from mobilerun.cli import deviceauth + from mobilerun.config_manager import env_keys + + 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 From 68fb2ea51297666f344af60eed3477d7e4213d76 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 20:18:56 +0100 Subject: [PATCH 27/59] feat(cli): support unattended input --- mobilerun/cli/auth.py | 20 +++- mobilerun/cli/configure.py | 48 ++++++++- mobilerun/cli/oauth_actions.py | 16 +++ mobilerun/cli/options.py | 43 +++++++- tests/test_cli_contract.py | 1 + tests/test_cli_devx.py | 191 +++++++++++++++++++++++++++++++++ tests/test_grok_cli.py | 6 ++ 7 files changed, 317 insertions(+), 8 deletions(-) diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py index dd46b5c8..c2567fe8 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -9,6 +9,7 @@ from mobilerun.cli import deviceauth from mobilerun.cli.configure_wizard import _OAUTH_CREDENTIAL_SLOTS, _oauth_credentials_present from mobilerun.cli.oauth_actions import ( + login_requires_input, run_anthropic_setup_token_oauth, run_gemini_oauth_login, run_grok_oauth_login, @@ -19,8 +20,11 @@ Group, auth_url_option, emit, + input_allowed, json_option, model_option, + no_input_option, + read_secret, verbose_option, ) from mobilerun.cli.qr import print_qr @@ -194,7 +198,12 @@ def auth_cli(verbose): @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.") -@click.option("--token", help="Save an Anthropic setup-token.") +@no_input_option +@click.option( + "--token", callback=read_secret, + help="Anthropic setup-token; prefer '-' for stdin or ANTHROPIC_API_KEY with configure, " + "because flag values end up in shell history.", +) @click.pass_context def login( ctx, @@ -212,6 +221,7 @@ def login( callback_path, device_code, token, + no_input, ): """Log in to a service; SERVICE defaults to mobilerun.""" for name in set().union(*LOGIN_OPTIONS.values()): @@ -220,6 +230,14 @@ def login( 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 or DEFAULT_AUTH_URL, client_id or DEFAULT_DEVICE_CLIENT_ID, no_qr diff --git a/mobilerun/cli/configure.py b/mobilerun/cli/configure.py index c5004fa4..823d8db4 100644 --- a/mobilerun/cli/configure.py +++ b/mobilerun/cli/configure.py @@ -9,13 +9,20 @@ _run_grok_oauth_login, _run_openai_oauth_login, ) -from mobilerun.cli.configure_wizard import ConfigureWizardCallbacks, run_configure_wizard +from mobilerun.cli.configure_wizard import ( + ConfigureWizardCallbacks, + _oauth_credentials_present, + run_configure_wizard, +) from mobilerun.cli.options import ( CONTEXT_SETTINGS, Command, config_option, + input_allowed, llm_url_option, model_option, + no_input_option, + read_secret, verbose_option, ) from mobilerun.cli.providers import PROVIDERS, configure_provider_option @@ -30,15 +37,22 @@ @llm_url_option @verbose_option @config_option -@click.option("-k", "--api-key", help="API key for the selected provider.") +@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): +def configure(provider, model, llm_url, verbose, config_path, api_key, auth_mode, no_input): """Configure provider, authentication and model with the wizard.""" spec = PROVIDERS.get(provider) if spec: if spec.family is None: raise click.UsageError(f"Provider {provider} is supported by run only") 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( @@ -54,3 +68,31 @@ def configure(provider, model, llm_url, verbose, config_path, api_key, auth_mode 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.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/oauth_actions.py b/mobilerun/cli/oauth_actions.py index 2ffd1144..8bbd18ec 100644 --- a/mobilerun/cli/oauth_actions.py +++ b/mobilerun/cli/oauth_actions.py @@ -1,15 +1,23 @@ from __future__ import annotations +import os + from mobilerun.agent.utils.oauth.anthropic_oauth_llm import ( DEFAULT_SETUP_TOKEN_SCOPE, AnthropicOAuthLLM, ) +from mobilerun.agent.utils.oauth.anthropic_oauth_llm import ( + _is_headless_environment as anthropic_headless, +) from mobilerun.agent.utils.oauth.gemini_oauth_code_assist_llm import ( DEFAULT_MODEL as GEMINI_OAUTH_DEFAULT_MODEL, ) from mobilerun.agent.utils.oauth.gemini_oauth_code_assist_llm import ( GeminiOAuthCodeAssistLLM, ) +from mobilerun.agent.utils.oauth.gemini_oauth_code_assist_llm import ( + _is_headless_environment as gemini_headless, +) from mobilerun.agent.utils.oauth.grok_oauth_llm import ( DEFAULT_GROK_MODEL, GrokOAuth, @@ -197,3 +205,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.""" + headless = {"anthropic": anthropic_headless, "gemini": gemini_headless}.get(service) + return bool(headless and ( + headless() or os.environ.get("MOBILERUN_OAUTH_MANUAL", "").lower() in {"1", "true", "yes"} + )) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index 8749843b..1f3b7999 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -32,6 +32,27 @@ def local_device_hint(message): return message +def read_secret(ctx, param, value): + """Accept one secret line from stdin, without echoing it.""" + if value == "-": + value = click.get_text_stream("stdin").readline().rstrip("\r\n") + if not value.strip(): + raise click.BadParameter("stdin is empty; provide one secret line.", ctx=ctx, param=param) + if value: + ctx.meta.setdefault("cli_secrets", []).append(value) + return value + + +def redact_secrets(text, ctx): + for secret in sorted(set(ctx.meta.get("cli_secrets", ())), key=len, reverse=True): + text = text.replace(secret, "[redacted]") + return text + + +def input_allowed(no_input=False): + return not no_input and sys.stdin.isatty() + + def _sdk_error(exc): while exc is not None: if isinstance(exc, (APIStatusError, APIConnectionError)): @@ -47,6 +68,9 @@ def convert_api_error(exc): return None request = exc.request sensitive = [os.environ.get("MOBILERUN_CLOUD_API_KEY", "")] + ctx = click.get_current_context(silent=True) + if ctx: + sensitive.extend(ctx.meta.get("cli_secrets", ())) authorization = request.headers.get("authorization", "") if authorization.lower().startswith("bearer "): sensitive.append(authorization[7:]) @@ -133,7 +157,13 @@ def invoke(self, ctx): logger = logging.getLogger("mobilerun") previous = logger.handlers[:], logger.level, logger.propagate - configure_logging(bool(ctx.params.get("verbose")), logging.StreamHandler(sys.stderr)) + class SecretFormatter(logging.Formatter): + def format(self, record): + return redact_secrets(super().format(record), ctx) + + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(SecretFormatter()) + configure_logging(bool(ctx.params.get("verbose")), handler) try: # Libraries and OAuth flows may print progress directly. with redirect_stdout(sys.stderr): @@ -142,8 +172,12 @@ def invoke(self, ctx): if ctx.command.name != "doctor": ConfigLoader.resolve_path(ctx.params.get("config_path")) return super().invoke(ctx) - except (KeyboardInterrupt, asyncio.CancelledError, click.Abort): + except (KeyboardInterrupt, asyncio.CancelledError): ctx.exit(130) + except (EOFError, click.Abort) as exc: + if isinstance(exc.__cause__, KeyboardInterrupt): + ctx.exit(130) + raise click.ClickException("Input closed; run the command again in a terminal.") from None except click.exceptions.Exit: raise except Exception as exc: @@ -156,9 +190,9 @@ def invoke(self, ctx): else: if not isinstance(exc, click.ClickException): if ctx.params.get("verbose"): - traceback.print_exc(file=sys.stderr) + click.echo(redact_secrets(traceback.format_exc(), ctx), err=True) exc = click.ClickException(str(exc) or type(exc).__name__) - exc.message = local_device_hint(exc.message) + exc.message = local_device_hint(redact_secrets(exc.message, ctx)) document = {"error": exc.message} if (not isinstance(exc, click.UsageError) and ctx.params.get("json_output") and not ctx.obj["emitted"]): @@ -197,6 +231,7 @@ def resolve_command(self, ctx, args): # 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", diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index 92f1f72a..e027b380 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -555,6 +555,7 @@ def test_configure_api_provider_preselects_api_key_and_validates_auth_mode(monke configure = importlib.import_module("mobilerun.cli.configure") wizard = Mock() monkeypatch.setattr(configure, "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" diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index 25daa8d4..a1b60f1f 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -321,3 +321,194 @@ def test_env_text_uses_empty_values_lowercase_booleans_and_one_secret_status(mon 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, tty, no_input, flags, missing, tmp_path): + from mobilerun.config_manager import env_keys + + configure = importlib.import_module("mobilerun.cli.configure") + monkeypatch.setattr(configure, "input_allowed", lambda disabled: tty and not disabled) + 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(configure, "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"]) +@pytest.mark.parametrize("command", ["configure", "token"]) +def test_secret_from_stdin_is_one_line_and_never_printed(monkeypatch, stdin, command, tmp_path): + from mobilerun.cli import auth + + secret = "dummy-marker" + if command == "configure": + module = importlib.import_module("mobilerun.cli.configure") + callback = Mock() + monkeypatch.setattr(module, "run_configure_wizard", callback) + args = ["configure", "-p", "openai", "-m", "MODEL", "-k", "-", "--no-input", "-v"] + else: + callback = Mock() + monkeypatch.setattr(auth, "save_anthropic_setup_token", callback) + args = ["auth", "login", "anthropic", "--token", "-", "--credential-path", str(tmp_path / "auth.json"), + "--no-input", "-v"] + result = CliRunner().invoke(cli, args, input=stdin) + assert result.exit_code == 0, result.output + assert secret not in result.output and "second-line" not in result.output + if command == "configure": + assert callback.call_args.kwargs["api_key"] == secret + else: + assert callback.call_args.args[1] == secret + + +@pytest.mark.parametrize("stdin", ["", "\n", " \n"]) +@pytest.mark.parametrize("args", [["configure", "-k", "-"], ["auth", "login", "anthropic", "--token", "-"]]) +def test_empty_secret_stdin_is_usage_error(args, stdin): + result = CliRunner().invoke(cli, args, input=stdin) + assert result.exit_code == 2, result.output + assert "stdin is empty" in result.stderr + + +@pytest.mark.parametrize("from_stdin", [False, True]) +@pytest.mark.parametrize("verbose", [False, True]) +@pytest.mark.parametrize("command", ["configure", "token"]) +def test_secrets_are_redacted_from_errors_and_verbose_logs(monkeypatch, from_stdin, verbose, command, tmp_path): + import logging + + from mobilerun.cli import auth + + secret = "dummy-error-marker" + + def fail(*args, **kwargs): + logging.getLogger("mobilerun").error("failure containing %s", secret) + raise RuntimeError("upstream failure containing " + secret) + + if command == "configure": + module = importlib.import_module("mobilerun.cli.configure") + monkeypatch.setattr(module, "run_configure_wizard", fail) + args = ["configure", "-p", "openai", "-m", "MODEL", "-k", "-" if from_stdin else secret, "--no-input"] + else: + monkeypatch.setattr(auth, "save_anthropic_setup_token", fail) + args = ["auth", "login", "anthropic", "--token", "-" if from_stdin else secret, + "--credential-path", str(tmp_path / "auth.json"), "--no-input"] + result = CliRunner().invoke(cli, [*args, *(["-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,needs_input", [("mobilerun", False), ("openai", False), ("xai", False), + ("anthropic", True), ("gemini", True)]) +def test_login_only_blocks_flows_that_need_terminal_input(monkeypatch, service, needs_input): + from mobilerun.cli import auth + + monkeypatch.setattr(auth, "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 == (2 if needs_input else 0), result.output + assert sum(callback.call_count for callback in callbacks) == int(not needs_input) + if service == "anthropic": + assert "--token" in result.stderr + if service == "gemini": + assert "terminal" in result.stderr and "--no-input" in result.stderr + + +@pytest.mark.parametrize("service", ["anthropic", "gemini"]) +def test_provider_callback_login_without_manual_prompt_is_allowed(monkeypatch, service): + from mobilerun.cli import auth + + monkeypatch.setattr(auth, "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, failure, code): + configure = importlib.import_module("mobilerun.cli.configure") + monkeypatch.setattr(configure, "input_allowed", lambda _: True) + monkeypatch.setattr(configure, "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" + + +@pytest.mark.parametrize("cause,code", [(EOFError, 1), (KeyboardInterrupt, 130)]) +def test_click_prompt_abort_cause_preserves_exit_code(monkeypatch, cause, code): + configure = importlib.import_module("mobilerun.cli.configure") + monkeypatch.setattr(configure, "input_allowed", lambda _: True) + + def abort(*args, **kwargs): + try: + raise cause + except cause as exc: + raise click.Abort from exc + + monkeypatch.setattr(configure, "run_configure_wizard", abort) + result = CliRunner().invoke(cli, ["configure"]) + assert result.exit_code == code, result.output + + +def test_no_input_configure_resolves_provider_environment_key(monkeypatch): + configure = importlib.import_module("mobilerun.cli.configure") + monkeypatch.setenv("OPENAI_API_KEY", "dummy-environment-marker") + wizard = Mock() + monkeypatch.setattr(configure, "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): + configure = importlib.import_module("mobilerun.cli.configure") + monkeypatch.setattr(configure, "_oauth_credentials_present", lambda *args: False) + wizard = Mock() + monkeypatch.setattr(configure, "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): + configure = importlib.import_module("mobilerun.cli.configure") + monkeypatch.setattr(configure, "input_allowed", lambda _: True) + monkeypatch.setattr(configure, "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 + + +def test_manual_login_detection_reuses_provider_flow_selection(monkeypatch): + from mobilerun.cli import oauth_actions + + monkeypatch.setattr(oauth_actions, "anthropic_headless", lambda: False) + monkeypatch.setattr(oauth_actions, "gemini_headless", lambda: False) + monkeypatch.delenv("MOBILERUN_OAUTH_MANUAL", raising=False) + assert not oauth_actions.login_requires_input("gemini") + monkeypatch.setenv("MOBILERUN_OAUTH_MANUAL", "true") + assert oauth_actions.login_requires_input("gemini") + assert oauth_actions.login_requires_input("anthropic") + assert not oauth_actions.login_requires_input("openai") + monkeypatch.delenv("MOBILERUN_OAUTH_MANUAL") + monkeypatch.setattr(oauth_actions, "gemini_headless", lambda: True) + assert oauth_actions.login_requires_input("gemini") diff --git a/tests/test_grok_cli.py b/tests/test_grok_cli.py index 8f321f2c..784ce929 100644 --- a/tests/test_grok_cli.py +++ b/tests/test_grok_cli.py @@ -75,6 +75,7 @@ def test_configure_xai_command_forwards_device_code_options( 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, @@ -114,6 +115,7 @@ def test_configure_anthropic_forwards_timeout_and_browser_preference( 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, @@ -280,6 +282,10 @@ def choose_model( # type: ignore[no-untyped-def] 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], From 3478721a1a05c26238d7983a37355ec29edb6ae6 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 20:21:23 +0100 Subject: [PATCH 28/59] feat(cli): add shell completion --- README.md | 86 +++++++++++++++++++++++++++++++++---- mobilerun/cli/completion.py | 15 +++++++ mobilerun/cli/main.py | 3 ++ mobilerun/cli/options.py | 2 +- tests/test_cli_contract.py | 2 +- tests/test_cli_devx.py | 29 ++++++++++++- 6 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 mobilerun/cli/completion.py diff --git a/README.md b/README.md index aa3f7c9c..b6f9a9db 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,8 @@ mobilerun ├── device │ ├── list │ ├── pair -│ ├── connect SERIAL -│ ├── disconnect SERIAL +│ ├── connect HOST:PORT +│ ├── disconnect HOST:PORT │ ├── setup │ ├── ping │ ├── tap X Y @@ -179,22 +179,26 @@ mobilerun │ ├── ui │ ├── apps │ └── start PACKAGE +├── configure ├── auth │ ├── login [SERVICE] │ ├── logout [SERVICE] │ └── status -├── configure +├── doctor +├── env ├── macro │ ├── replay PATH │ └── list [DIRECTORY] -├── doctor -└── env +└── completion SHELL ``` 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 provider choices in help include `openrouter` and -`deepseek` for `run` only. See each command's help for its arguments and options. +runs the wizard. The `configure` provider choices include only supported +providers; `run` and `macro replay` also accept `openrouter` and `deepseek`. +Help includes examples, and command typos suggest a correction without executing +it. The `run --agent` option appears only when an external agent is installed. +See each command's help for its arguments and options. ### Shared flags @@ -226,13 +230,15 @@ screenshot, ui, apps, start, and ping. | — | `--auth-url` | Mobilerun login: sign-in endpoint. | | — | `--tcp` / `--no-tcp` | Run and macro replay: local Android TCP mode. | | — | `--no-qr` | Mobilerun login: suppress the QR code. | +| — | `--no-input` | Configure and auth login: never prompt for input. | ### Environment For CLI options, flags override environment values, then config values and defaults. 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. +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 | | --- | --- | --- | @@ -249,7 +255,7 @@ configuration and credential store. | `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 terminal styling where supported | 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 | @@ -258,6 +264,10 @@ configuration and credential store. ### 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: @@ -279,11 +289,69 @@ document on failure; API errors include the HTTP status and an optional code: {"error":"Device not found","status":404,"code":null} ``` +`doctor -j` always retains its checks, including when it exits 1: + +```json +{"ok":false,"checks":[{"name":"Device","status":"fail","detail":"No device connected"}]} +``` + +`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. + 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 +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`. 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: diff --git a/mobilerun/cli/completion.py b/mobilerun/cli/completion.py new file mode 100644 index 00000000..61023e38 --- /dev/null +++ b/mobilerun/cli/completion.py @@ -0,0 +1,15 @@ +"""Print Click's shell completion scripts without installing them.""" + +import click +from click.shell_completion import get_completion_class + +from mobilerun.cli.options import CONTEXT_SETTINGS, Command, emit + + +@click.command(cls=Command, context_settings=CONTEXT_SETTINGS) +@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"])) +def completion(shell): + """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/main.py b/mobilerun/cli/main.py index 48fa5c37..2b2dcb6c 100644 --- a/mobilerun/cli/main.py +++ b/mobilerun/cli/main.py @@ -27,6 +27,7 @@ def cli(verbose): 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 @@ -40,3 +41,5 @@ def cli(verbose): cli.add_command(macro_cli, "macro") cli.add_command(doctor) cli.add_command(env) + +cli.add_command(completion) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index 1f3b7999..24c2391a 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -169,7 +169,7 @@ def format(self, record): with redirect_stdout(sys.stderr): from mobilerun.config_manager import ConfigLoader - if ctx.command.name != "doctor": + if ctx.command.name not in {"doctor", "completion"}: ConfigLoader.resolve_path(ctx.params.get("config_path")) return super().invoke(ctx) except (KeyboardInterrupt, asyncio.CancelledError): diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index e027b380..94af0a34 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -49,7 +49,7 @@ def test_every_command_has_help(path, command, flag): def test_tree_and_short_options(): - assert set(cli.commands) == {"run", "device", "auth", "configure", "macro", "doctor", "env"} + 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", diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index a1b60f1f..4fb2256c 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -61,7 +61,7 @@ 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"] + 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 ) @@ -512,3 +512,30 @@ def test_manual_login_detection_reuses_provider_flow_selection(monkeypatch): monkeypatch.delenv("MOBILERUN_OAUTH_MANUAL") monkeypatch.setattr(oauth_actions, "gemini_headless", lambda: True) assert oauth_actions.login_requires_input("gemini") + + +@pytest.mark.parametrize("shell", ["bash", "zsh", "fish"]) +def test_completion_is_click_source_without_installing(shell, tmp_path, monkeypatch): + from click.shell_completion import BashComplete, get_completion_class + + 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 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 From c41a9c70bfc3b42472c954469ac7a7e8449eab2f Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 20:52:18 +0100 Subject: [PATCH 29/59] fix(cli): resolve devx findings --- README.md | 7 +- mobilerun/cli/auth.py | 34 +- mobilerun/cli/completion.py | 4 +- mobilerun/cli/configure.py | 14 +- mobilerun/cli/device_commands.py | 24 +- mobilerun/cli/device_lifecycle.py | 21 +- mobilerun/cli/device_pair.py | 7 +- mobilerun/cli/doctor.py | 57 ++- mobilerun/cli/doctor_command.py | 16 +- mobilerun/cli/env.py | 19 +- mobilerun/cli/main.py | 9 +- mobilerun/cli/oauth_actions.py | 15 +- mobilerun/cli/options.py | 107 +++-- mobilerun/cli/run.py | 38 +- mobilerun/macro/cli.py | 6 +- tests/test_cli_devx.py | 732 +++++++++++++++++++++--------- 16 files changed, 752 insertions(+), 358 deletions(-) diff --git a/README.md b/README.md index b6f9a9db..c765a6b4 100644 --- a/README.md +++ b/README.md @@ -289,10 +289,10 @@ document on failure; API errors include the HTTP status and an optional code: {"error":"Device not found","status":404,"code":null} ``` -`doctor -j` always retains its checks, including when it exits 1: +`doctor -j` retains completed checks, including when it exits 1: ```json -{"ok":false,"checks":[{"name":"Device","status":"fail","detail":"No device connected"}]} +{"ok":false,"checks":[{"name":"Device","status":"fail","detail":"no device connected\nConnect a device over USB or run `mobilerun device pair`; use `-t cloud` for cloud devices."}]} ``` `device list --all` lists local devices first, then cloud devices. If either @@ -328,7 +328,8 @@ printf '%s\n' "$ANTHROPIC_SETUP_TOKEN" | mobilerun auth login anthropic --token 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`. Tokens and keys are never included in CLI errors +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 diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py index c2567fe8..37722ee9 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -17,12 +17,13 @@ save_anthropic_setup_token, ) from mobilerun.cli.options import ( + DEFAULT_AUTH_URL, Group, auth_url_option, emit, + examples, input_allowed, json_option, - model_option, no_input_option, read_secret, verbose_option, @@ -83,7 +84,6 @@ def _run_grok_oauth_login(credential_path: str, model: str | None = None, **kwar _print_oauth_login_success("XAI", credential_path) -DEFAULT_AUTH_URL = "https://cloud.mobilerun.ai/api/auth" DEFAULT_DEVICE_CLIENT_ID = "mobilerun-cli" @@ -175,15 +175,19 @@ def auth_cli(verbose): @auth_cli.command( - epilog='\b\nExamples:\n mobilerun auth login\n mobilerun auth login openai --no-browser', + epilog=examples( + 'mobilerun auth login', + 'mobilerun auth login openai --no-browser', + ), ) @service_argument @verbose_option @auth_url_option -@model_option(help="LLM model (openai, gemini, xai).") +@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)." + "--credential-path", type=click.Path(dir_okay=False), + help="Provider credential file (openai, anthropic, gemini, xai)." ) @click.option( "--timeout", @@ -192,16 +196,22 @@ def auth_cli(verbose): 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).") +@click.option( + "--no-browser", is_flag=True, + help="Do not open a provider login browser (openai, anthropic, gemini, xai).", +) @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-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 or ANTHROPIC_API_KEY with configure, " + help="Anthropic setup-token; prefer '-' for stdin, " "because flag values end up in shell history.", ) @click.pass_context @@ -232,7 +242,9 @@ def login( 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.") + 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 " @@ -240,12 +252,12 @@ def login( ) if service == "mobilerun": _login_mobilerun( - auth_url or DEFAULT_AUTH_URL, client_id or DEFAULT_DEVICE_CLIENT_ID, no_qr + auth_url, client_id or DEFAULT_DEVICE_CLIENT_ID, no_qr ) return kwargs = { "credential_path": credential_path or str(SERVICES[service][0]), - "timeout": timeout or 300.0, + "timeout": timeout, "open_browser": not no_browser, } if service in {"openai", "gemini", "anthropic"}: diff --git a/mobilerun/cli/completion.py b/mobilerun/cli/completion.py index 61023e38..9a63106f 100644 --- a/mobilerun/cli/completion.py +++ b/mobilerun/cli/completion.py @@ -3,10 +3,10 @@ import click from click.shell_completion import get_completion_class -from mobilerun.cli.options import CONTEXT_SETTINGS, Command, emit +from mobilerun.cli.options import Command, emit -@click.command(cls=Command, context_settings=CONTEXT_SETTINGS) +@click.command(cls=Command) @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"])) def completion(shell): """Print the completion script for bash, zsh, or fish.""" diff --git a/mobilerun/cli/configure.py b/mobilerun/cli/configure.py index 823d8db4..ec40a5b9 100644 --- a/mobilerun/cli/configure.py +++ b/mobilerun/cli/configure.py @@ -15,9 +15,9 @@ run_configure_wizard, ) from mobilerun.cli.options import ( - CONTEXT_SETTINGS, Command, config_option, + examples, input_allowed, llm_url_option, model_option, @@ -29,8 +29,11 @@ @click.command( - cls=Command, context_settings=CONTEXT_SETTINGS, - epilog='\b\nExamples:\n mobilerun configure\n mobilerun configure -p openai -m MODEL', + cls=Command, + epilog=examples( + 'mobilerun configure', + 'mobilerun configure -p openai -m MODEL', + ), ) @configure_provider_option @model_option @@ -48,8 +51,6 @@ def configure(provider, model, llm_url, verbose, config_path, api_key, auth_mode """Configure provider, authentication and model with the wizard.""" spec = PROVIDERS.get(provider) if spec: - if spec.family is None: - raise click.UsageError(f"Provider {provider} is supported by run only") 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) @@ -87,7 +88,8 @@ def _validate_non_interactive(provider, model, api_key, base_url, auth_mode): 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." + "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.") diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index f3ffb934..e7095aef 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -4,7 +4,6 @@ import base64 import json import tempfile -import unicodedata from collections.abc import Mapping from contextlib import contextmanager @@ -13,9 +12,11 @@ from mobilerun.agent.utils.android_device import is_portal_http_url from mobilerun.cli.options import ( Group, + _ui_cell, device_options, emit, emit_rows, + examples, json_option, verbose_option, ) @@ -60,7 +61,11 @@ def probe_device(params, *, selection=None): @click.group( cls=Group, - epilog='\b\nExamples:\n mobilerun device list\n mobilerun device pair\n mobilerun device ui -d DEVICE', + epilog=examples( + 'mobilerun device list', + 'mobilerun device pair', + 'mobilerun device ui -d DEVICE', + ), ) @verbose_option def device_cli(verbose): @@ -84,13 +89,6 @@ def screenshot(json_output, **params): emit({"path": path} if json_output else path, json_output=json_output) -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 _field(node, *keys): for key in keys: value = node.get(key) @@ -136,7 +134,10 @@ def ui_lines(state, all_elements=False): @device_cli.command( - epilog='\b\nExamples:\n mobilerun device ui -d DEVICE\n mobilerun device ui -d DEVICE -j', + 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.") @@ -233,7 +234,8 @@ def apps(system, json_output, **params): 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", ""), + (_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 )) diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index 1d6065fc..97ad8389 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -6,6 +6,7 @@ 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, api_url_option, config_option, @@ -14,8 +15,10 @@ device_option, emit, emit_rows, + examples, json_option, platform_option, + redact_secrets, target_option, verbose_option, ) @@ -24,7 +27,10 @@ @device_cli.command( - "list", epilog='\b\nExamples:\n mobilerun device list\n mobilerun device list --all -j', + "list", epilog=examples( + 'mobilerun device list', + 'mobilerun device list --all -j', + ), ) @target_option @api_url_option @@ -34,14 +40,16 @@ def list_devices(target, api_url, verbose, json_output, all_devices): """List devices in the selected target.""" rows, errors = [], [] - for scope in (["local", "cloud"] if all_devices else [target]): + 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[0] if converted else str(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: @@ -50,9 +58,10 @@ def list_devices(target, api_url, verbose, json_output, all_devices): emit_rows(("DEVICE_ID", "NAME", "STATE", "TYPE"), ( (row["device_id"], row["name"] or "-", row["state"], row["type"]) for row in rows )) - else: - click.echo("No devices found. " + NO_LOCAL_DEVICES_HINT, err=True) - if len(errors) == 2: + 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) diff --git a/mobilerun/cli/device_pair.py b/mobilerun/cli/device_pair.py index 22fb3326..faec4ad7 100644 --- a/mobilerun/cli/device_pair.py +++ b/mobilerun/cli/device_pair.py @@ -13,7 +13,7 @@ import click from mobilerun.cli.device_commands import device_cli -from mobilerun.cli.options import emit, verbose_option +from mobilerun.cli.options import emit, examples, verbose_option from mobilerun.cli.qr import print_qr @@ -151,7 +151,10 @@ def _wait_for_transport(guid: str, deadline: float) -> str: @device_cli.command( - epilog='\b\nExamples:\n mobilerun device pair\n mobilerun device pair --timeout 180', + epilog=examples( + 'mobilerun device pair', + 'mobilerun device pair --timeout 180', + ), ) @verbose_option @click.option( diff --git a/mobilerun/cli/doctor.py b/mobilerun/cli/doctor.py index d4ba2dfa..85c98ddf 100644 --- a/mobilerun/cli/doctor.py +++ b/mobilerun/cli/doctor.py @@ -652,23 +652,32 @@ async def run_doctor( device_serial: str | None = None, debug: bool = False, config_path: str | None = None, - *, target="local", platform=None, api_url=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, config_path) - _print_result(r_config, debug) + report(r_config, debug) results.append(r_config) # Load serial from config if not provided @@ -688,10 +697,10 @@ async def run_doctor( # 3. ADB r = await check_adb(debug) - _print_result(r, debug) + report(r, debug) results.append(r) if r_config.status == Status.FAIL or (r.status == Status.FAIL and not non_adb): - _print_summary(results) + summary(results) return results if non_adb: @@ -704,22 +713,22 @@ async def run_doctor( except Exception as exc: result = CheckResult("Device", Status.FAIL, str(exc)) results.append(result) - _print_result(result, debug) - _print_summary(results) + 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) + 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.setup import _setup_portal @@ -729,13 +738,13 @@ async def run_doctor( r_portal, is_installed = await check_portal_installed(device, debug) if not is_installed: results.append(r_portal) - _print_summary(results) + 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...[/]") @@ -758,55 +767,55 @@ 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 diff --git a/mobilerun/cli/doctor_command.py b/mobilerun/cli/doctor_command.py index ab430059..3c2c9a8d 100644 --- a/mobilerun/cli/doctor_command.py +++ b/mobilerun/cli/doctor_command.py @@ -2,9 +2,8 @@ import click -from mobilerun.cli.doctor import Status, console, run_doctor +from mobilerun.cli.doctor import Status, run_doctor from mobilerun.cli.options import ( - CONTEXT_SETTINGS, Command, coro, device_options, @@ -14,21 +13,20 @@ ) -@click.command(cls=Command, context_settings=CONTEXT_SETTINGS) +@click.command(cls=Command) @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.""" - # The check runner renders progress and a summary for Python callers. - # The CLI emits its own single text/JSON result instead. - with console.capture(): - results = await run_doctor(device, bool(verbose), config_path, - target=target, platform=platform, api_url=api_url) + 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))))} + "detail": local_device_hint("; ".join(filter(None, (result.message, result.detail))), target)} for result in results ] if json_output: diff --git a/mobilerun/cli/env.py b/mobilerun/cli/env.py index 15a46f38..d1bb2b54 100644 --- a/mobilerun/cli/env.py +++ b/mobilerun/cli/env.py @@ -5,10 +5,9 @@ import click from mobilerun.cli import deviceauth -from mobilerun.cli.auth import DEFAULT_AUTH_URL from mobilerun.cli.options import ( - CONTEXT_SETTINGS, DEFAULT_API_URL, + DEFAULT_AUTH_URL, Command, config_option, emit, @@ -21,6 +20,9 @@ from mobilerun.config_manager.env_keys import API_KEY_ENV_VARS, load_env_key_sources from mobilerun.telemetry.tracker import is_telemetry_enabled +TRUTHY = {"1", "true", "yes", "on"} +BOOLEAN_VARIABLES = {"MOBILERUN_DEBUG", "MOBILERUN_STREAM_SCREENSHOTS", "MOBILERUN_OAUTH_MANUAL"} + def resolved_environment(config_path=None): path = ConfigLoader.resolve_path(config_path) @@ -75,8 +77,8 @@ def resolved_environment(config_path=None): value, source = os.environ[name], "env" if name == "MOBILERUN_TELEMETRY_ENABLED": value = is_telemetry_enabled(config_enabled=config.telemetry.enabled) - if name == "MOBILERUN_DEBUG" and source == "env": - value = value.lower() in {"1", "true", "yes", "on"} + if name in BOOLEAN_VARIABLES and isinstance(value, str): + value = value.lower() in TRUTHY if secret and not value: source = "not set" if secret: @@ -85,7 +87,7 @@ def resolved_environment(config_path=None): return rows -@click.command(cls=Command, context_settings=CONTEXT_SETTINGS) +@click.command(cls=Command) @config_option @verbose_option @json_option @@ -97,13 +99,10 @@ def env(config_path, verbose, json_output): else: for row in rows: value = row["value"] - if isinstance(value, str) and row["variable"] in { - "MOBILERUN_STREAM_SCREENSHOTS", "MOBILERUN_OAUTH_MANUAL", - }: - value = value.lower() in {"1", "true", "yes", "on"} if isinstance(value, bool): value = str(value).lower() elif value is None: value = "" - source = f" ({row['source']})" if row["source"] != value else "" + show_source = not (value == "not set" and row["source"] == "not set") + source = f" ({row['source']})" if show_source else "" emit(f"{row['variable']}={value}{source}") diff --git a/mobilerun/cli/main.py b/mobilerun/cli/main.py index 2b2dcb6c..2834140f 100644 --- a/mobilerun/cli/main.py +++ b/mobilerun/cli/main.py @@ -6,7 +6,7 @@ import click -from mobilerun.cli.options import Group, verbose_option +from mobilerun.cli.options import Group, examples, verbose_option from mobilerun.cli.run import run, run_command __all__ = ["cli", "run_command"] @@ -15,7 +15,12 @@ @click.group( cls=Group, command_order=["run", "device", "configure", "auth", "doctor", "env", "macro", "completion"], - epilog='\b\nExamples:\n mobilerun device list\n mobilerun configure -p openai\n mobilerun run "Open Settings"\n\nDocs: https://docs.mobilerun.ai/framework/guides/cli', + 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 diff --git a/mobilerun/cli/oauth_actions.py b/mobilerun/cli/oauth_actions.py index 8bbd18ec..99fcf03b 100644 --- a/mobilerun/cli/oauth_actions.py +++ b/mobilerun/cli/oauth_actions.py @@ -5,9 +5,7 @@ from mobilerun.agent.utils.oauth.anthropic_oauth_llm import ( DEFAULT_SETUP_TOKEN_SCOPE, AnthropicOAuthLLM, -) -from mobilerun.agent.utils.oauth.anthropic_oauth_llm import ( - _is_headless_environment as anthropic_headless, + _is_headless_environment, ) from mobilerun.agent.utils.oauth.gemini_oauth_code_assist_llm import ( DEFAULT_MODEL as GEMINI_OAUTH_DEFAULT_MODEL, @@ -15,9 +13,6 @@ from mobilerun.agent.utils.oauth.gemini_oauth_code_assist_llm import ( GeminiOAuthCodeAssistLLM, ) -from mobilerun.agent.utils.oauth.gemini_oauth_code_assist_llm import ( - _is_headless_environment as gemini_headless, -) from mobilerun.agent.utils.oauth.grok_oauth_llm import ( DEFAULT_GROK_MODEL, GrokOAuth, @@ -209,7 +204,7 @@ def get_default_openai_credential_path() -> str: def login_requires_input(service): """Use each provider's existing flow selection to identify manual prompts.""" - headless = {"anthropic": anthropic_headless, "gemini": gemini_headless}.get(service) - return bool(headless and ( - headless() or os.environ.get("MOBILERUN_OAUTH_MANUAL", "").lower() in {"1", "true", "yes"} - )) + 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 index 24c2391a..63423b5f 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -3,9 +3,11 @@ 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 @@ -13,42 +15,65 @@ from mobilerun_sdk import APIConnectionError, APIStatusError, APITimeoutError DEFAULT_API_URL = "https://api.mobilerun.ai/v1" -CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} +DEFAULT_AUTH_URL = "https://cloud.mobilerun.ai/api/auth" +CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"], "show_default": True} +SECRETS_KEY = "cli_secrets" 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." -def local_device_hint(message): + +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_LOCAL_DEVICES_HINT + 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): - return message + "\nFind connected devices with `mobilerun device list`." + command = "mobilerun device list -t cloud" if target == "cloud" else "mobilerun device list" + return message + f"\nFind connected devices with `{command}`." return message def read_secret(ctx, param, value): """Accept one secret line from stdin, without echoing it.""" + if ctx.resilient_parsing: + return value if value == "-": - value = click.get_text_stream("stdin").readline().rstrip("\r\n") + stream = click.get_text_stream("stdin") + 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") if not value.strip(): raise click.BadParameter("stdin is empty; provide one secret line.", ctx=ctx, param=param) if value: - ctx.meta.setdefault("cli_secrets", []).append(value) + ctx.meta.setdefault(SECRETS_KEY, []).append(value) return value -def redact_secrets(text, ctx): - for secret in sorted(set(ctx.meta.get("cli_secrets", ())), key=len, reverse=True): +def redact(text, secrets): + for secret in sorted(set(filter(None, secrets)), 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() @@ -70,16 +95,13 @@ def convert_api_error(exc): sensitive = [os.environ.get("MOBILERUN_CLOUD_API_KEY", "")] ctx = click.get_current_context(silent=True) if ctx: - sensitive.extend(ctx.meta.get("cli_secrets", ())) + sensitive.extend(ctx.meta.get(SECRETS_KEY, ())) authorization = request.headers.get("authorization", "") if authorization.lower().startswith("bearer "): sensitive.append(authorization[7:]) def clean(value): - text = " ".join(str(value).split()) - for secret in sorted(filter(None, sensitive), key=len, reverse=True): - text = text.replace(secret, "[redacted]") - return text + 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 {} @@ -124,16 +146,14 @@ class HelpLayout: def __init__(self, *args, **kwargs): kwargs.setdefault("context_settings", CONTEXT_SETTINGS) super().__init__(*args, **kwargs) - for param in self.params: - if isinstance(param, click.Option) and param.show_default is None: - param.show_default = True def get_params(self, ctx): - first = {"device": 0, "target": 1, "platform": 2, "config_path": 3} - last = {"api_url": 5, "json_output": 6, "verbose": 7, "help": 8} - return sorted(super().get_params(ctx), key=lambda param: first.get( - param.name, last.get(param.name, 4) - )) + 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()) @@ -151,18 +171,12 @@ def invoke(self, ctx): ctx.params["verbose"] = parent.params["verbose"] break parent = parent.parent - import logging - from mobilerun.log_handlers import configure_logging logger = logging.getLogger("mobilerun") previous = logger.handlers[:], logger.level, logger.propagate - class SecretFormatter(logging.Formatter): - def format(self, record): - return redact_secrets(super().format(record), ctx) - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(SecretFormatter()) + handler.setFormatter(SecretFormatter(ctx)) configure_logging(bool(ctx.params.get("verbose")), handler) try: # Libraries and OAuth flows may print progress directly. @@ -175,7 +189,7 @@ def format(self, record): except (KeyboardInterrupt, asyncio.CancelledError): ctx.exit(130) except (EOFError, click.Abort) as exc: - if isinstance(exc.__cause__, KeyboardInterrupt): + if isinstance(exc.__cause__ or exc.__context__, KeyboardInterrupt): ctx.exit(130) raise click.ClickException("Input closed; run the command again in a terminal.") from None except click.exceptions.Exit: @@ -192,7 +206,9 @@ def format(self, record): 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)) + 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"]): @@ -284,7 +300,7 @@ def resolve_command(self, ctx, args): ) auth_url_option = click.option( "--auth-url", - default="https://cloud.mobilerun.ai/api/auth", + default=DEFAULT_AUTH_URL, envvar="MOBILERUN_AUTH_URL", show_envvar=True, help="Mobilerun sign-in URL.", @@ -293,9 +309,7 @@ def resolve_command(self, ctx, args): json_option = click.option( "-j", "--json", "json_output", is_flag=True, help="Print one JSON document." ) -def model_option(function=None, *, help="LLM model."): - decorator = click.option("-m", "--model", help=help) - return decorator(function) if function is not None else decorator +model_option = click.option("-m", "--model", help="LLM model.") llm_url_option = click.option("-u", "--llm-url", help="LLM API URL.") @@ -326,22 +340,37 @@ def wrapper(*args, **kwargs): 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 = ctx.obj.get("stdout") if ctx and ctx.obj else sys.stdout + 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.""" - ctx = click.get_current_context(silent=True) - output = ctx.obj.get("stdout") if ctx and ctx.obj else sys.stdout + output = stdout() def line(values): - return "\t".join(str(value if value is not None else "").replace("\t", " ") - .replace("\r", " ").replace("\n", " ") for value in 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/run.py b/mobilerun/cli/run.py index f0411bed..7e67e89e 100644 --- a/mobilerun/cli/run.py +++ b/mobilerun/cli/run.py @@ -17,12 +17,12 @@ from mobilerun.agent.utils.llm_picker import load_llm from mobilerun.cli.event_handler import EventHandler from mobilerun.cli.options import ( - CONTEXT_SETTINGS, DEFAULT_API_URL, Command, coro, device_options, emit, + examples, json_option, llm_url_option, max_steps_option, @@ -287,28 +287,33 @@ async def _cleanup_android_keyboard(config: MobileConfig, serial: str | None = N class AgentOption(click.Option): + def installed_type(self): + self.type = click.Choice(list_agents()) + return self.type + def get_help_record(self, ctx): - names = list_agents() - if not names: + if not self.installed_type().choices: return None - self.type = click.Choice(names) return super().get_help_record(ctx) def type_cast_value(self, ctx, value): - if value is not None: - names = list_agents() - if not names: - raise click.BadParameter( - "No external agents are installed; install one before using --agent.", - ctx=ctx, param=self, - ) - self.type = click.Choice(names) + 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, context_settings=CONTEXT_SETTINGS, - epilog='\b\nExamples:\n mobilerun run "Open Settings"\n mobilerun run "Open Settings" -d DEVICE -p openai -m MODEL', + cls=Command, + epilog=examples( + 'mobilerun run "Open Settings"', + 'mobilerun run "Open Settings" -d DEVICE -p openai -m MODEL', + ), ) @click.argument("task") @device_options @@ -327,7 +332,10 @@ def type_cast_value(self, ctx, value): @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.") +@click.option( + "--save-trajectory", type=click.Choice(["none", "step", "action"]), + help="Choose which trajectory data to save.", +) @coro async def run( task, diff --git a/mobilerun/macro/cli.py b/mobilerun/macro/cli.py index da6ea434..c72469d5 100644 --- a/mobilerun/macro/cli.py +++ b/mobilerun/macro/cli.py @@ -11,6 +11,7 @@ from mobilerun.agent.utils.trajectory import Trajectory from mobilerun.cli.options import ( + examples, Group, coro, device_options, @@ -58,7 +59,10 @@ def macro_cli(verbose): @macro_cli.command( - epilog='\b\nExamples:\n mobilerun macro replay MACRO.json -n\n mobilerun macro replay TRAJECTORY -d DEVICE', + epilog=examples( + 'mobilerun macro replay MACRO.json -n', + 'mobilerun macro replay TRAJECTORY -d DEVICE', + ), ) @click.argument("path", type=click.Path(exists=True)) @device_options diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index 4fb2256c..d6d4be17 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -1,16 +1,37 @@ """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 pytest +from click.shell_completion import BashComplete, get_completion_class from click.testing import CliRunner - +from mobilerun_sdk import APIConnectionError, APIStatusError + +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.config_manager import ConfigLoader +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=()): @@ -28,16 +49,64 @@ def isolated_config(monkeypatch, tmp_path): monkeypatch.delenv(name, raising=False) -@pytest.mark.parametrize("path", [(), ("run",), ("device",), ("device", "list"), - ("device", "ui"), ("device", "pair"), ("auth", "login"), - ("configure",), ("macro", "replay")]) +@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(configure, "run_configure_wizard", callback) + args = ["configure", "-p", "openai", "-m", "MODEL", "-k"] + + def value(): + return callback.call_args.kwargs["api_key"] + else: + monkeypatch.setattr(auth, "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") + assert result.stdout.rstrip().endswith( + "Docs: https://docs.mobilerun.ai/framework/guides/cli" + ) @pytest.mark.parametrize("path,command", list(commands())) @@ -49,16 +118,22 @@ def test_help_options_and_summaries_are_complete(path, command): 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 + 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"))) + 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"] @@ -67,24 +142,37 @@ def test_root_command_order_and_agent_discovery(monkeypatch): ) -def test_agent_option_tracks_installed_agents(monkeypatch): +def test_agent_option_hidden_without_installed_agents(monkeypatch): monkeypatch.setattr(run_module, "list_agents", lambda: []) - runner = CliRunner() - assert "--agent" not in runner.invoke(cli, ["run", "--help"]).stdout - result = runner.invoke(cli, ["run", "TASK", "-a", "missing"]) + 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" - assert runner.invoke(cli, ["run", "TASK", "-a", "missing"]).exit_code == 2 -@pytest.mark.parametrize("args,suggestion", [(["devcie"], "device"), (["device", "lst"], "list"), - (["auth", "stauts"], "status")]) +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"], "device"), (["device", "lst"], "list"), (["auth", "stauts"], "status")], +) def test_command_typos_suggest_without_accepting(args, suggestion): result = CliRunner().invoke(cli, args) assert result.exit_code == 2 @@ -98,60 +186,71 @@ def test_no_suggestion_for_unrelated_command(): @pytest.mark.parametrize("path", [("run",), ("configure",), ("macro", "replay")]) -def test_provider_help_wraps_and_configure_only_lists_supported(path): +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 - if path == ("configure",): - assert "openrouter" not in result.stdout and "deepseek" not in result.stdout - for name in ("openrouter", "deepseek"): - assert CliRunner().invoke(cli, ["configure", "-p", name]).exit_code == 2 -def test_login_and_connection_usage(): +@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 - for command in ("connect", "disconnect"): - assert "HOST:PORT" in runner.invoke(cli, ["device", command, "--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): - import json - - doctor = importlib.import_module("mobilerun.cli.doctor_command") - from mobilerun.cli.doctor import CheckResult, Status, console async def check(*args, **kwargs): - console.print("Device: duplicate progress") - console.print("Device: duplicate summary") + 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(doctor, "run_doctor", check) + monkeypatch.setattr(doctor_command, "run_doctor", check) result = CliRunner().invoke(cli, ["doctor", *(["-j"] if json_output else [])]) assert result.exit_code == int(failed), result.output - assert "duplicate" not in 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"}], + "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"), -]) +@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): - from mobilerun.cli import device_lifecycle monkeypatch.setattr(device_lifecycle, "probe_device", Mock(side_effect=RuntimeError(message))) result = CliRunner().invoke(cli, ["device", "ping", "-d", "missing"]) @@ -163,9 +262,6 @@ def test_local_error_next_step(monkeypatch, message, hint): @pytest.mark.parametrize("tty", [False, True]) def test_rows_only_include_terminal_header(tty): - from io import StringIO - - from mobilerun.cli.options import emit_rows class Output(StringIO): def isatty(self): @@ -173,27 +269,58 @@ def isatty(self): output = Output() with click.Context(cli, obj={"stdout": output}): - emit_rows(("NAME", "DETAIL"), [("first", "line\nbreak\tcolumn"), ("last", None)]) - expected = "first\tline break column\nlast\t\n" + 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.mark.parametrize("local_failure,cloud_failure", [(False, False), (False, True), - (True, False), (True, True)]) -@pytest.mark.parametrize("json_output", [False, True]) -def test_all_devices_retains_partial_results_and_closes(monkeypatch, local_failure, cloud_failure, json_output): - import json - from types import SimpleNamespace - - from mobilerun.cli import device_lifecycle - - clients, devices = [], [] - calls = [] +@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, _conn=object(), capabilities={"backend": scope}, close=Mock()) + device = SimpleNamespace( + device_id=scope, _conn=object(), capabilities={"backend": scope}, close=Mock() + ) devices.append(device) def listing(**kwargs): @@ -207,33 +334,33 @@ def listing(**kwargs): return client monkeypatch.setattr(device_lifecycle, "core_client", factory) - result = CliRunner().invoke(cli, ["device", "list", "--all", *(["-j"] if json_output else [])]) - assert result.exit_code == int(local_failure and cloud_failure), result.output + yield code, ids, errors, warnings assert calls == ["local", "cloud"] - assert "Error: 1" not in result.output - expected = [target for target, failed in (("local", local_failure), ("cloud", cloud_failure)) if not failed] - if json_output: - data = json.loads(result.stdout) - assert [row["device_id"] for row in data["devices"]] == expected - assert data["errors"] == [{"target": target, "error": target + " unavailable"} - for target, failed in (("local", local_failure), ("cloud", cloud_failure)) if failed] - else: - assert [line.split("\t")[0] for line in result.stdout.splitlines()] == expected - assert result.stderr.count("Warning: cloud:") == int(cloud_failure) - assert result.stderr.count("Warning: local:") == int(local_failure) for client in clients: client.close.assert_called_once() - for device, failed in zip(devices, (local_failure, cloud_failure), strict=True): - assert device.close.call_count == int(not failed) + assert [device.close.call_count for device in devices] == close_counts -@pytest.mark.parametrize("json_output", [False, True]) -def test_all_devices_with_no_key_and_empty_local_is_success(monkeypatch, json_output): - import json - from types import SimpleNamespace +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 - from mobilerun.cli import device_lifecycle, selection +@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) @@ -243,22 +370,29 @@ def factory(url, *, cloud): return local_client monkeypatch.setattr(device_lifecycle, "core_client", factory) - result = CliRunner().invoke(cli, ["device", "list", "--all", *(["-j"] if json_output else [])]) + + +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 - if json_output: - data = json.loads(result.stdout) - assert data["devices"] == [] - assert "No cloud API key" in data["errors"][0]["error"] - else: - assert "USB" in result.stderr and "mobilerun device pair" in result.stderr and "-t cloud" in result.stderr + assert ( + "USB" in result.stderr + and "mobilerun device pair" in result.stderr + and "-t cloud" in result.stderr + ) -def test_connection_hint_preserves_port_and_hides_url_secrets(): - import httpx - from mobilerun_sdk import APIConnectionError +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"] - from mobilerun.cli.options import convert_api_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)) @@ -269,17 +403,20 @@ def test_connection_hint_preserves_port_and_hides_url_secrets(): @pytest.mark.parametrize("app_field", ["package_name", "app_id", "bundle_id", "package"]) def test_apps_are_tsv(monkeypatch, app_field): - from contextlib import contextmanager - from types import SimpleNamespace - - from mobilerun.cli import device_commands @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}, - ]) + 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"]) @@ -287,23 +424,25 @@ def driver(*args, **kwargs): assert result.stdout == "example.app\tExample\t1.0\t1\tfalse\n" - def test_macro_list_is_tsv(tmp_path): - import json - - from mobilerun.macro.state import MACRO_SCHEMA_VERSION 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})) + (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): - from mobilerun.cli import deviceauth - from mobilerun.config_manager import env_keys monkeypatch.setattr(deviceauth, "load_credentials", lambda: None) monkeypatch.setattr(env_keys, "resolve_env_key", lambda *args: None) @@ -324,17 +463,30 @@ def test_env_text_uses_empty_values_lowercase_booleans_and_one_secret_status(mon @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, tty, no_input, flags, missing, tmp_path): - from mobilerun.config_manager import env_keys - - configure = importlib.import_module("mobilerun.cli.configure") - monkeypatch.setattr(configure, "input_allowed", lambda disabled: tty and not disabled) +@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"))) + monkeypatch.setattr( + ConfigLoader, "load", Mock(side_effect=AssertionError("must not load or create config")) + ) wizard = Mock(side_effect=AssertionError("must not prompt")) monkeypatch.setattr(configure, "run_configure_wizard", wizard) result = CliRunner().invoke(cli, ["configure", *flags, *(["--no-input"] if no_input else [])]) @@ -344,92 +496,82 @@ def test_configure_without_input_fails_before_loading_or_writing(monkeypatch, tt assert list(tmp_path.iterdir()) == [] -@pytest.mark.parametrize("stdin", ["dummy-marker\nsecond-line\n", "dummy-marker\r\n", "dummy-marker"]) -@pytest.mark.parametrize("command", ["configure", "token"]) -def test_secret_from_stdin_is_one_line_and_never_printed(monkeypatch, stdin, command, tmp_path): - from mobilerun.cli import auth - - secret = "dummy-marker" - if command == "configure": - module = importlib.import_module("mobilerun.cli.configure") - callback = Mock() - monkeypatch.setattr(module, "run_configure_wizard", callback) - args = ["configure", "-p", "openai", "-m", "MODEL", "-k", "-", "--no-input", "-v"] - else: - callback = Mock() - monkeypatch.setattr(auth, "save_anthropic_setup_token", callback) - args = ["auth", "login", "anthropic", "--token", "-", "--credential-path", str(tmp_path / "auth.json"), - "--no-input", "-v"] - result = CliRunner().invoke(cli, args, input=stdin) +@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 secret not in result.output and "second-line" not in result.output - if command == "configure": - assert callback.call_args.kwargs["api_key"] == secret - else: - assert callback.call_args.args[1] == secret + 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"]) -@pytest.mark.parametrize("args", [["configure", "-k", "-"], ["auth", "login", "anthropic", "--token", "-"]]) -def test_empty_secret_stdin_is_usage_error(args, stdin): - result = CliRunner().invoke(cli, args, input=stdin) +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]) -@pytest.mark.parametrize("command", ["configure", "token"]) -def test_secrets_are_redacted_from_errors_and_verbose_logs(monkeypatch, from_stdin, verbose, command, tmp_path): - import logging - - from mobilerun.cli import auth - +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) - if command == "configure": - module = importlib.import_module("mobilerun.cli.configure") - monkeypatch.setattr(module, "run_configure_wizard", fail) - args = ["configure", "-p", "openai", "-m", "MODEL", "-k", "-" if from_stdin else secret, "--no-input"] - else: - monkeypatch.setattr(auth, "save_anthropic_setup_token", fail) - args = ["auth", "login", "anthropic", "--token", "-" if from_stdin else secret, - "--credential-path", str(tmp_path / "auth.json"), "--no-input"] - result = CliRunner().invoke(cli, [*args, *(["-v"] if verbose else [])], input=secret + "\n" if from_stdin else None) + 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,needs_input", [("mobilerun", False), ("openai", False), ("xai", False), - ("anthropic", True), ("gemini", True)]) -def test_login_only_blocks_flows_that_need_terminal_input(monkeypatch, service, needs_input): - from mobilerun.cli import auth +@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(auth, "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"): + 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 == (2 if needs_input else 0), result.output - assert sum(callback.call_count for callback in callbacks) == int(not needs_input) - if service == "anthropic": - assert "--token" in result.stderr - if service == "gemini": - assert "terminal" in result.stderr and "--no-input" in result.stderr + 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): - from mobilerun.cli import auth monkeypatch.setattr(auth, "login_requires_input", lambda _: False) callback = Mock() @@ -440,9 +582,7 @@ def test_provider_callback_login_without_manual_prompt_is_allowed(monkeypatch, s @pytest.mark.parametrize("failure,code", [(EOFError, 1), (KeyboardInterrupt, 130)]) -def test_prompt_eof_and_interrupt_have_distinct_exit_codes(monkeypatch, failure, code): - configure = importlib.import_module("mobilerun.cli.configure") - monkeypatch.setattr(configure, "input_allowed", lambda _: True) +def test_prompt_eof_and_interrupt_have_distinct_exit_codes(monkeypatch, allow_input, failure, code): monkeypatch.setattr(configure, "run_configure_wizard", Mock(side_effect=failure)) result = CliRunner().invoke(cli, ["configure"]) assert result.exit_code == code, result.output @@ -450,24 +590,20 @@ def test_prompt_eof_and_interrupt_have_distinct_exit_codes(monkeypatch, failure, assert result.stderr == "Error: Input closed; run the command again in a terminal.\n" -@pytest.mark.parametrize("cause,code", [(EOFError, 1), (KeyboardInterrupt, 130)]) -def test_click_prompt_abort_cause_preserves_exit_code(monkeypatch, cause, code): - configure = importlib.import_module("mobilerun.cli.configure") - monkeypatch.setattr(configure, "input_allowed", lambda _: True) +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") - def abort(*args, **kwargs): - try: - raise cause - except cause as exc: - raise click.Abort from exc - - monkeypatch.setattr(configure, "run_configure_wizard", abort) + monkeypatch.setattr(configure, "run_configure_wizard", prompt) result = CliRunner().invoke(cli, ["configure"]) - assert result.exit_code == code, result.output + assert result.exit_code == 130, result.output + assert "Input closed" not in result.output def test_no_input_configure_resolves_provider_environment_key(monkeypatch): - configure = importlib.import_module("mobilerun.cli.configure") monkeypatch.setenv("OPENAI_API_KEY", "dummy-environment-marker") wizard = Mock() monkeypatch.setattr(configure, "run_configure_wizard", wizard) @@ -478,45 +614,50 @@ def test_no_input_configure_resolves_provider_environment_key(monkeypatch): def test_no_input_configure_never_starts_missing_oauth_login(monkeypatch): - configure = importlib.import_module("mobilerun.cli.configure") monkeypatch.setattr(configure, "_oauth_credentials_present", lambda *args: False) wizard = Mock() monkeypatch.setattr(configure, "run_configure_wizard", wizard) - result = CliRunner().invoke(cli, ["configure", "-p", "openai-oauth", "-m", "MODEL", "--no-input"]) + 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): - configure = importlib.import_module("mobilerun.cli.configure") - monkeypatch.setattr(configure, "input_allowed", lambda _: True) - monkeypatch.setattr(configure, "run_configure_wizard", lambda *args, **kwargs: click.prompt("Value")) +def test_click_prompt_eof_is_one_failure_sentence(monkeypatch, allow_input): + monkeypatch.setattr( + configure, "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 -def test_manual_login_detection_reuses_provider_flow_selection(monkeypatch): - from mobilerun.cli import oauth_actions - - monkeypatch.setattr(oauth_actions, "anthropic_headless", lambda: False) - monkeypatch.setattr(oauth_actions, "gemini_headless", lambda: False) - monkeypatch.delenv("MOBILERUN_OAUTH_MANUAL", raising=False) - assert not oauth_actions.login_requires_input("gemini") - monkeypatch.setenv("MOBILERUN_OAUTH_MANUAL", "true") - assert oauth_actions.login_requires_input("gemini") - assert oauth_actions.login_requires_input("anthropic") - assert not oauth_actions.login_requires_input("openai") - monkeypatch.delenv("MOBILERUN_OAUTH_MANUAL") - monkeypatch.setattr(oauth_actions, "gemini_headless", lambda: True) - assert oauth_actions.login_requires_input("gemini") +@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): - from click.shell_completion import BashComplete, get_completion_class monkeypatch.setattr(BashComplete, "_check_version", staticmethod(lambda: None)) monkeypatch.setenv("MOBILERUN_CONFIG", str(tmp_path / "missing.yaml")) @@ -524,14 +665,22 @@ def test_completion_is_click_source_without_installing(shell, tmp_path, monkeypa 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", - }) + 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"] @@ -539,3 +688,172 @@ def test_completion_suggests_device_subcommands(): 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 From bc2946c18a535b1421dbb1c77b39ef6c762f19cb Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 21:02:28 +0100 Subject: [PATCH 30/59] fix(cli): suggest full command paths --- README.md | 5 ++- mobilerun/cli/configure.py | 15 +++++++- mobilerun/cli/options.py | 38 ++++++++++++++++-- tests/test_cli_devx.py | 79 +++++++++++++++++++++++++++++++++++++- 4 files changed, 130 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c765a6b4..6ea64a4b 100644 --- a/README.md +++ b/README.md @@ -196,8 +196,9 @@ 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, and command typos suggest a correction without executing -it. The `run --agent` option appears only when an external agent is installed. +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 diff --git a/mobilerun/cli/configure.py b/mobilerun/cli/configure.py index ec40a5b9..1f41f373 100644 --- a/mobilerun/cli/configure.py +++ b/mobilerun/cli/configure.py @@ -4,6 +4,7 @@ from rich.console import Console from mobilerun.cli.auth import ( + LOGIN_OPTIONS, _run_anthropic_oauth_login, _run_gemini_oauth_login, _run_grok_oauth_login, @@ -28,8 +29,20 @@ 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=Command, + cls=ConfigureCommand, epilog=examples( 'mobilerun configure', 'mobilerun configure -p openai -m MODEL', diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index 63423b5f..9e2c0f6b 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -219,6 +219,21 @@ def invoke(self, ctx): 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)) + + class Group(HelpLayout, click.Group): command_class = Command group_class = type @@ -239,12 +254,29 @@ def resolve_command(self, ctx, args): try: return super().resolve_command(ctx, args) except click.UsageError as exc: - if args and exc.message.startswith("No such command"): - matches = difflib.get_close_matches(args[0], self.list_commands(ctx), n=1, cutoff=0.6) + if not ctx.resilient_parsing and args and exc.message.startswith("No such command"): + matches = self.suggest_commands(ctx, args[0]) if matches: - exc.message += f" Did you mean '{matches[0]}'?" + suggestions = ", ".join(f"'{match}'" for match 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 = [" ".join(path) for path, _ in paths if path[-1] == name] + if exact: + return exact + if name in _RENAMED_COMMANDS: + return [_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 [" ".join((*parent_path, siblings[0]))] + closest = difflib.get_close_matches(name, [path[-1] for path, _ in paths], n=1, cutoff=0.6) + if closest: + return [next(" ".join(path) for path, _ 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.") diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index d6d4be17..6fa3435d 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -171,7 +171,11 @@ def test_unknown_agent_is_rejected(monkeypatch): @pytest.mark.parametrize( "args,suggestion", - [(["devcie"], "device"), (["device", "lst"], "list"), (["auth", "stauts"], "status")], + [ + (["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) @@ -857,3 +861,76 @@ def test_doctor_runner_suppresses_results_and_keeps_instructions(monkeypatch, fl 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 From 746f560871ede56ba9ffb3d7312f3faff138acb1 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 21:12:59 +0100 Subject: [PATCH 31/59] fix(cli): explain cloud credential source --- README.md | 1 + mobilerun/cli/auth.py | 17 ++- mobilerun/cli/env.py | 11 +- mobilerun/cli/options.py | 13 +- mobilerun/cli/selection.py | 26 ++-- tests/test_cli_devx.py | 215 ++++++++++++++++++++++++++++++++++ tests/test_cli_polish_fix2.py | 10 +- 7 files changed, 276 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6ea64a4b..40fa89f8 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ screenshot, ui, apps, start, and ping. ### Environment For CLI options, flags override environment values, then config values and defaults. +`MOBILERUN_CLOUD_API_KEY` overrides a saved sign-in; unset it to use the sign-in. 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 diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py index 37722ee9..55f50f15 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -29,6 +29,7 @@ verbose_option, ) from mobilerun.cli.qr import print_qr +from mobilerun.cli.selection import resolve_cloud_credential from mobilerun.config_manager.auth_profile_store import AuthProfileStore from mobilerun.config_manager.credential_paths import ( ANTHROPIC_OAUTH_CREDENTIAL_PATH, @@ -254,6 +255,11 @@ def login( _login_mobilerun( auth_url, client_id or DEFAULT_DEVICE_CLIENT_ID, no_qr ) + if resolve_cloud_credential().source == "env": + click.echo( + "Warning: MOBILERUN_CLOUD_API_KEY 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]), @@ -309,12 +315,16 @@ def status(verbose, json_output): """Show saved sign-in status without exposing tokens.""" credentials = deviceauth.load_credentials() or {} token = credentials.get("access_token") + credential = resolve_cloud_credential() + cloud_label = {"env": "env key", "login": "saved sign-in", None: "not set"}[credential.source] rows = [ { "service": "mobilerun", "signed_in": bool(token), "credential_path": str(deviceauth.credential_path()), "email": None, + "cloud_credential": cloud_label, + "env_overrides_login": credential.source == "env" and credential.saved_sign_in, } ] failed = False @@ -347,4 +357,9 @@ def status(verbose, json_output): for row in rows: label = "signed in" if row["signed_in"] else "not signed in" email = f" ({row['email']})" if row.get("email") else "" - emit(f"{row['service']}: {label}{email} {row['credential_path']}") + cloud = "" + if row["service"] == "mobilerun": + cloud = f"; cloud commands use {row['cloud_credential']}" + if row["env_overrides_login"]: + cloud += " (overrides saved sign-in)" + emit(f"{row['service']}: {label}{email} {row['credential_path']}{cloud}") diff --git a/mobilerun/cli/env.py b/mobilerun/cli/env.py index d1bb2b54..e82a8288 100644 --- a/mobilerun/cli/env.py +++ b/mobilerun/cli/env.py @@ -14,7 +14,7 @@ json_option, verbose_option, ) -from mobilerun.cli.selection import resolve_cloud_api_key +from mobilerun.cli.selection import resolve_cloud_credential from mobilerun.config_manager import ConfigLoader, MobileConfig from mobilerun.config_manager.config_manager import PORTAL_TOKEN_ENV_VARS from mobilerun.config_manager.env_keys import API_KEY_ENV_VARS, load_env_key_sources @@ -35,14 +35,14 @@ def resolved_environment(config_path=None): == click.core.ParameterSource.COMMANDLINE) else "env" if os.environ.get("MOBILERUN_CONFIG") else "default" ) - cloud_key = resolve_cloud_api_key() + 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_CLOUD_API_KEY": ( - cloud_key, - "credential file" if cloud_key else "not set", + cloud_credential.value, + {"env": "env", "login": "credential file", None: "not set"}[cloud_credential.source], True, ), "MOBILERUN_API_BASE_URL": (DEFAULT_API_URL, "default", False), @@ -73,7 +73,8 @@ def resolved_environment(config_path=None): values[name] = (None, "default", True) rows = [] for name, (value, source, secret) in values.items(): - if os.environ.get(name) and not (name == "MOBILERUN_CONFIG" and path_source == "flag"): + if (name != "MOBILERUN_CLOUD_API_KEY" and os.environ.get(name) + and not (name == "MOBILERUN_CONFIG" and path_source == "flag")): value, source = os.environ[name], "env" if name == "MOBILERUN_TELEMETRY_ENABLED": value = is_telemetry_enabled(config_enabled=config.telemetry.enabled) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index 9e2c0f6b..dc56618c 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -123,7 +123,18 @@ def clean(value): text = f"{message} (HTTP {status})" if status is not None else message hint = None if status in {401, 403}: - hint = "Run `mobilerun auth login` or set MOBILERUN_CLOUD_API_KEY." + from mobilerun.cli.selection import resolve_cloud_credential + + credential = resolve_cloud_credential() + document["credential"] = credential.source or "login" + if credential.source == "env": + hint = "The key in MOBILERUN_CLOUD_API_KEY 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: diff --git a/mobilerun/cli/selection.py b/mobilerun/cli/selection.py index a8a6b22d..784fb816 100644 --- a/mobilerun/cli/selection.py +++ b/mobilerun/cli/selection.py @@ -1,6 +1,6 @@ """Resolve CLI config and device selection before invoking a backend.""" -from dataclasses import dataclass +from dataclasses import dataclass, field import click import mobilerun_core @@ -17,17 +17,29 @@ from mobilerun.config_manager.config_manager import PORTAL_TOKEN_ENV_VARS -def resolve_cloud_api_key(): +@dataclass(frozen=True) +class CloudCredential: + value: str | None = field(repr=False) + source: str | None + saved_sign_in: bool + + +def resolve_cloud_credential(): + """Resolve the cloud bearer and its source without changing precedence.""" import os from mobilerun.cli import deviceauth credentials = deviceauth.load_credentials() or {} - return ( - os.environ.get("MOBILERUN_CLOUD_API_KEY") - or credentials.get("api_key") - or credentials.get("access_token") - ) + saved = credentials.get("api_key") or credentials.get("access_token") + env_key = os.environ.get("MOBILERUN_CLOUD_API_KEY") + if env_key: + return CloudCredential(env_key, "env", bool(saved)) + 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): diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index 6fa3435d..e78d8d46 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -934,3 +934,218 @@ def test_completion_never_suggests_command_corrections(words, cword): assert result.stderr == "" assert "Did you mean" not in result.stdout assert "to sign in" not in result.stdout + + +@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): + env, saved, key, source, has_login, label, env_source, overrides, hint = request.param + monkeypatch.delenv("MOBILERUN_CLOUD_API_KEY", raising=False) + if env is not None: + monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", 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(selection.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, []) + assert result.stdout == "" + assert ( + result.stderr + == f"Error: Rejected [redacted] (HTTP {status})\n{cloud_credential_case.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"]) + 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{cloud_credential_case.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(auth, "_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.label, + "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_CLOUD_API_KEY" + ) + assert row == { + "variable": "MOBILERUN_CLOUD_API_KEY", + "value": "set", + "source": cloud_credential_case.env_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_CLOUD_API_KEY=set ({cloud_credential_case.env_source})\n" in result.stdout + assert "planted-env-key" not in result.output + assert "planted-saved-token" not in result.output diff --git a/tests/test_cli_polish_fix2.py b/tests/test_cli_polish_fix2.py index 0b2247f7..24684f84 100644 --- a/tests/test_cli_polish_fix2.py +++ b/tests/test_cli_polish_fix2.py @@ -11,7 +11,7 @@ from mobilerun_core import UiState from mobilerun_sdk import APIConnectionError, APIStatusError, Mobilerun -from mobilerun.cli import device_lifecycle, selection +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 @@ -300,6 +300,7 @@ def respond(request, kind=kind, status=status, body=body): 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), )) @@ -329,7 +330,10 @@ def test_api_errors_text(monkeypatch, sdk_error_cases): 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") - assert json.loads(result.stdout) == {"error": message, "status": status, "code": code} + 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): @@ -337,4 +341,4 @@ def test_wrapped_api_error_uses_sdk_cause(monkeypatch, sdk_error_cases): 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} + assert json.loads(result.stdout) == {"error": message, "status": status, "code": code, "credential": "env"} From 134a79ea77c0e74d432c9a20a117c0fa08514a82 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 22:10:14 +0100 Subject: [PATCH 32/59] perf(cli): defer startup imports --- mobilerun/__init__.py | 92 ++++++++----- mobilerun/agent/__init__.py | 23 +++- mobilerun/agent/droid/__init__.py | 36 +++-- mobilerun/agent/utils/__init__.py | 51 ++++++-- mobilerun/cli/auth.py | 55 ++++++-- mobilerun/cli/configure.py | 28 +++- mobilerun/cli/device_commands.py | 17 ++- mobilerun/cli/device_lifecycle.py | 17 ++- mobilerun/cli/doctor_command.py | 19 ++- mobilerun/cli/env.py | 29 ++++- mobilerun/cli/options.py | 42 +++++- mobilerun/cli/providers.py | 20 ++- mobilerun/cli/qr.py | 18 ++- mobilerun/cli/run.py | 54 ++++++-- mobilerun/cli/selection.py | 67 ++++++++-- mobilerun/config_manager/__init__.py | 76 ++++++++--- mobilerun/macro/cli.py | 30 ++++- tests/test_cli_contract.py | 24 ++-- tests/test_cli_devx.py | 24 ++-- tests/test_cli_polish_fix2.py | 2 +- tests/test_cli_startup.py | 181 ++++++++++++++++++++++++++ tests/test_ios_local_portal_wiring.py | 4 +- tests/test_keyboard_cleanup.py | 4 +- tests/test_telemetry.py | 2 +- tests/test_vision_only_run_cli.py | 8 +- 25 files changed, 760 insertions(+), 163 deletions(-) create mode 100644 tests/test_cli_startup.py diff --git a/mobilerun/__init__.py b/mobilerun/__init__.py index 6ef29531..5f15ce5b 100644 --- a/mobilerun/__init__.py +++ b/mobilerun/__init__.py @@ -3,7 +3,9 @@ """ import logging +from importlib import import_module from importlib.metadata import version +from typing import TYPE_CHECKING __version__ = version("mobilerun") @@ -17,11 +19,7 @@ _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.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 ( @@ -30,32 +28,55 @@ ): logging.getLogger(_retry_logger).addFilter(HtmlErrorPageFilter()) -# Import configuration classes -from mobilerun.config_manager import ( # noqa: E402 - # Agent configs - AgentConfig, - AppCardConfig, - CredentialsConfig, - # 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.llm_picker import load_llm + from mobilerun.config_manager import ( + AgentConfig, + AppCardConfig, + CredentialsConfig, + 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_llm': ('mobilerun.agent.utils.llm_picker', 'load_llm'), + 'AgentConfig': ('mobilerun.config_manager', 'AgentConfig'), + 'AppCardConfig': ('mobilerun.config_manager', 'AppCardConfig'), + 'CredentialsConfig': ('mobilerun.config_manager', 'CredentialsConfig'), + '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__ = [ @@ -107,5 +128,14 @@ 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 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..7c9ac2da 100644 --- a/mobilerun/agent/__init__.py +++ b/mobilerun/agent/__init__.py @@ -1,3 +1,24 @@ -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 + 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..52bc4aa7 100644 --- a/mobilerun/agent/droid/__init__.py +++ b/mobilerun/agent/droid/__init__.py @@ -1,21 +1,25 @@ -""" -Mobilerun Agent Module. +"""Mobilerun Agent Module. -This module provides a ReAct agent for automating Android devices using reasoning and acting. -""" +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 -# Legacy aliases for backward compatibility -_LEGACY_ALIASES = { - "DroidAgent": MobileAgent, - "DroidAgentState": MobileAgentState, +if TYPE_CHECKING: + from mobilerun.agent.droid.droid_agent import MobileAgent + from mobilerun.agent.droid.state import 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 +27,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/utils/__init__.py b/mobilerun/agent/utils/__init__.py index 55edf33d..c34f7c0d 100644 --- a/mobilerun/agent/utils/__init__.py +++ b/mobilerun/agent/utils/__init__.py @@ -1,16 +1,28 @@ -""" -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 +37,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/cli/auth.py b/mobilerun/cli/auth.py index 55f50f15..c5c6ba06 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -1,5 +1,7 @@ """Sign-in commands using the existing credential flows.""" +from __future__ import annotations + import math from pathlib import Path @@ -7,15 +9,6 @@ from rich.console import Console from mobilerun.cli import deviceauth -from mobilerun.cli.configure_wizard import _OAUTH_CREDENTIAL_SLOTS, _oauth_credentials_present -from mobilerun.cli.oauth_actions import ( - login_requires_input, - run_anthropic_setup_token_oauth, - run_gemini_oauth_login, - run_grok_oauth_login, - run_openai_oauth_login, - save_anthropic_setup_token, -) from mobilerun.cli.options import ( DEFAULT_AUTH_URL, Group, @@ -57,17 +50,23 @@ def _print_oauth_login_success(provider_label: str, credential_path: str) -> Non 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, @@ -77,6 +76,8 @@ def _run_anthropic_oauth_login(credential_path: str, **kwargs) -> None: 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, @@ -235,6 +236,8 @@ def login( 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 @@ -296,6 +299,8 @@ def login( @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: @@ -313,6 +318,8 @@ def logout(service, verbose): @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() @@ -363,3 +370,33 @@ def status(verbose, json_output): if row["env_overrides_login"]: cloud += " (overrides saved sign-in)" emit(f"{row['service']}: {label}{email} {row['credential_path']}{cloud}") + + +from mobilerun.cli.options import _lazy_imports # noqa: E402 + +__getattr__ = _lazy_imports(globals(), { + '_OAUTH_CREDENTIAL_SLOTS': ('mobilerun.cli.configure_wizard', '_OAUTH_CREDENTIAL_SLOTS'), + '_oauth_credentials_present': ('mobilerun.cli.configure_wizard', '_oauth_credentials_present'), + 'login_requires_input': ('mobilerun.cli.oauth_actions', 'login_requires_input'), + 'run_anthropic_setup_token_oauth': ('mobilerun.cli.oauth_actions', 'run_anthropic_setup_token_oauth'), + 'run_gemini_oauth_login': ('mobilerun.cli.oauth_actions', 'run_gemini_oauth_login'), + 'run_grok_oauth_login': ('mobilerun.cli.oauth_actions', 'run_grok_oauth_login'), + 'run_openai_oauth_login': ('mobilerun.cli.oauth_actions', 'run_openai_oauth_login'), + 'save_anthropic_setup_token': ('mobilerun.cli.oauth_actions', 'save_anthropic_setup_token'), +}) + +from typing import TYPE_CHECKING # noqa: E402 + +if TYPE_CHECKING: + from mobilerun.cli.configure_wizard import _OAUTH_CREDENTIAL_SLOTS as _OAUTH_CREDENTIAL_SLOTS + from mobilerun.cli.configure_wizard import ( + _oauth_credentials_present as _oauth_credentials_present, + ) + from mobilerun.cli.oauth_actions import login_requires_input as login_requires_input + from mobilerun.cli.oauth_actions import ( + run_anthropic_setup_token_oauth as run_anthropic_setup_token_oauth, + ) + from mobilerun.cli.oauth_actions import run_gemini_oauth_login as run_gemini_oauth_login + from mobilerun.cli.oauth_actions import run_grok_oauth_login as run_grok_oauth_login + from mobilerun.cli.oauth_actions import run_openai_oauth_login as run_openai_oauth_login + from mobilerun.cli.oauth_actions import save_anthropic_setup_token as save_anthropic_setup_token diff --git a/mobilerun/cli/configure.py b/mobilerun/cli/configure.py index 1f41f373..9e90a0bb 100644 --- a/mobilerun/cli/configure.py +++ b/mobilerun/cli/configure.py @@ -1,5 +1,7 @@ """Interactive configuration command.""" +from __future__ import annotations + import click from rich.console import Console @@ -10,11 +12,6 @@ _run_grok_oauth_login, _run_openai_oauth_login, ) -from mobilerun.cli.configure_wizard import ( - ConfigureWizardCallbacks, - _oauth_credentials_present, - run_configure_wizard, -) from mobilerun.cli.options import ( Command, config_option, @@ -62,6 +59,8 @@ def parse_args(self, ctx, args): @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 @@ -87,6 +86,7 @@ def configure(provider, model, llm_url, verbose, config_path, api_key, auth_mode 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: @@ -111,3 +111,21 @@ def _validate_non_interactive(provider, model, api_key, base_url, auth_mode): f"Saved OAuth credentials required; run `mobilerun auth login {spec.family}` " "in a terminal first, or choose -p PROVIDER with API-key authentication." ) + + +from mobilerun.cli.options import _lazy_imports # noqa: E402 + +__getattr__ = _lazy_imports(globals(), { + 'ConfigureWizardCallbacks': ('mobilerun.cli.configure_wizard', 'ConfigureWizardCallbacks'), + '_oauth_credentials_present': ('mobilerun.cli.configure_wizard', '_oauth_credentials_present'), + 'run_configure_wizard': ('mobilerun.cli.configure_wizard', 'run_configure_wizard'), +}) + +from typing import TYPE_CHECKING # noqa: E402 + +if TYPE_CHECKING: + from mobilerun.cli.configure_wizard import ConfigureWizardCallbacks as ConfigureWizardCallbacks + from mobilerun.cli.configure_wizard import ( + _oauth_credentials_present as _oauth_credentials_present, + ) + from mobilerun.cli.configure_wizard import run_configure_wizard as run_configure_wizard diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index e7095aef..fa82f709 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -1,5 +1,7 @@ """Synchronous device actions through mobilerun-core.""" +from __future__ import annotations + import asyncio import base64 import json @@ -9,7 +11,6 @@ import click -from mobilerun.agent.utils.android_device import is_portal_http_url from mobilerun.cli.options import ( Group, _ui_cell, @@ -28,6 +29,8 @@ @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 + params = params if params is not None else click.get_current_context().params if selection is None: selection = asyncio.run(select_device( @@ -249,3 +252,15 @@ def start(package, **params): with device_driver(params) as device: device.start_app(package) click.echo(f"Started {package}", err=True) + + +from mobilerun.cli.options import _lazy_imports # noqa: E402 + +__getattr__ = _lazy_imports(globals(), { + 'is_portal_http_url': ('mobilerun.agent.utils.android_device', 'is_portal_http_url'), +}) + +from typing import TYPE_CHECKING # noqa: E402 + +if TYPE_CHECKING: + from mobilerun.agent.utils.android_device import is_portal_http_url as is_portal_http_url diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index 97ad8389..f58f7dad 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -1,5 +1,7 @@ """Device discovery and Android lifecycle commands.""" +from __future__ import annotations + import click from async_adbutils import adb @@ -23,7 +25,6 @@ verbose_option, ) from mobilerun.cli.selection import core_client, select_device -from mobilerun.cli.setup import _setup_portal @device_cli.command( @@ -124,6 +125,8 @@ async def disconnect(serial, verbose): @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) @@ -144,3 +147,15 @@ 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) + + +from mobilerun.cli.options import _lazy_imports # noqa: E402 + +__getattr__ = _lazy_imports(globals(), { + '_setup_portal': ('mobilerun.cli.setup', '_setup_portal'), +}) + +from typing import TYPE_CHECKING # noqa: E402 + +if TYPE_CHECKING: + from mobilerun.cli.setup import _setup_portal as _setup_portal diff --git a/mobilerun/cli/doctor_command.py b/mobilerun/cli/doctor_command.py index 3c2c9a8d..ac0168eb 100644 --- a/mobilerun/cli/doctor_command.py +++ b/mobilerun/cli/doctor_command.py @@ -1,8 +1,9 @@ """Click wiring for health checks.""" +from __future__ import annotations + import click -from mobilerun.cli.doctor import Status, run_doctor from mobilerun.cli.options import ( Command, coro, @@ -19,6 +20,8 @@ @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, @@ -36,3 +39,17 @@ async def doctor(device, config_path, platform, target, api_url, verbose, json_o emit(f"{check['name']}: {check['status']}: {check['detail']}") if failed: click.get_current_context().exit(1) + + +from mobilerun.cli.options import _lazy_imports # noqa: E402 + +__getattr__ = _lazy_imports(globals(), { + 'Status': ('mobilerun.cli.doctor', 'Status'), + 'run_doctor': ('mobilerun.cli.doctor', 'run_doctor'), +}) + +from typing import TYPE_CHECKING # noqa: E402 + +if TYPE_CHECKING: + from mobilerun.cli.doctor import Status as Status + from mobilerun.cli.doctor import run_doctor as run_doctor diff --git a/mobilerun/cli/env.py b/mobilerun/cli/env.py index e82a8288..ca911b5b 100644 --- a/mobilerun/cli/env.py +++ b/mobilerun/cli/env.py @@ -1,5 +1,7 @@ """Inspect resolved CLI environment values and their sources.""" +from __future__ import annotations + import os import click @@ -15,16 +17,17 @@ verbose_option, ) from mobilerun.cli.selection import resolve_cloud_credential -from mobilerun.config_manager import ConfigLoader, MobileConfig -from mobilerun.config_manager.config_manager import PORTAL_TOKEN_ENV_VARS from mobilerun.config_manager.env_keys import API_KEY_ENV_VARS, load_env_key_sources -from mobilerun.telemetry.tracker import is_telemetry_enabled 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() @@ -107,3 +110,23 @@ def env(config_path, verbose, json_output): show_source = not (value == "not set" and row["source"] == "not set") source = f" ({row['source']})" if show_source else "" emit(f"{row['variable']}={value}{source}") + + +from mobilerun.cli.options import _lazy_imports # noqa: E402 + +__getattr__ = _lazy_imports(globals(), { + 'ConfigLoader': ('mobilerun.config_manager', 'ConfigLoader'), + 'MobileConfig': ('mobilerun.config_manager', 'MobileConfig'), + 'PORTAL_TOKEN_ENV_VARS': ('mobilerun.config_manager.config_manager', 'PORTAL_TOKEN_ENV_VARS'), + 'is_telemetry_enabled': ('mobilerun.telemetry.tracker', 'is_telemetry_enabled'), +}) + +from typing import TYPE_CHECKING # noqa: E402 + +if TYPE_CHECKING: + from mobilerun.config_manager import ConfigLoader as ConfigLoader + from mobilerun.config_manager import MobileConfig as MobileConfig + from mobilerun.config_manager.config_manager import ( + PORTAL_TOKEN_ENV_VARS as PORTAL_TOKEN_ENV_VARS, + ) + from mobilerun.telemetry.tracker import is_telemetry_enabled as is_telemetry_enabled diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index dc56618c..79e48af4 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -1,5 +1,7 @@ """Shared Click options and command IO policy.""" +from __future__ import annotations + import asyncio import difflib import json @@ -12,7 +14,6 @@ from functools import wraps import click -from mobilerun_sdk import APIConnectionError, APIStatusError, APITimeoutError DEFAULT_API_URL = "https://api.mobilerun.ai/v1" DEFAULT_AUTH_URL = "https://cloud.mobilerun.ai/api/auth" @@ -79,6 +80,8 @@ def input_allowed(no_input=False): def _sdk_error(exc): + from mobilerun_sdk import APIConnectionError, APIStatusError + while exc is not None: if isinstance(exc, (APIStatusError, APIConnectionError)): return exc @@ -88,6 +91,8 @@ def _sdk_error(exc): 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 @@ -192,9 +197,9 @@ def invoke(self, ctx): try: # Libraries and OAuth flows may print progress directly. with redirect_stdout(sys.stderr): - from mobilerun.config_manager import ConfigLoader - if ctx.command.name not in {"doctor", "completion"}: + from mobilerun.config_manager import ConfigLoader + ConfigLoader.resolve_path(ctx.params.get("config_path")) return super().invoke(ctx) except (KeyboardInterrupt, asyncio.CancelledError): @@ -417,3 +422,34 @@ def 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 "") + + + +def _lazy_imports(namespace, exports): + """Preserve module export paths while deferring optional dependencies.""" + from importlib import import_module + + def get_attribute(name): + if name not in exports: + raise AttributeError(f"module {namespace['__name__']!r} has no attribute {name!r}") + module, attribute = exports[name] + value = import_module(module) + if attribute is not None: + value = getattr(value, attribute) + namespace[name] = value + return value + + return get_attribute + +__getattr__ = _lazy_imports(globals(), { + 'APIConnectionError': ('mobilerun_sdk', 'APIConnectionError'), + 'APIStatusError': ('mobilerun_sdk', 'APIStatusError'), + 'APITimeoutError': ('mobilerun_sdk', 'APITimeoutError'), +}) + +from typing import TYPE_CHECKING # noqa: E402 + +if TYPE_CHECKING: + from mobilerun_sdk import APIConnectionError as APIConnectionError + from mobilerun_sdk import APIStatusError as APIStatusError + from mobilerun_sdk import APITimeoutError as APITimeoutError diff --git a/mobilerun/cli/providers.py b/mobilerun/cli/providers.py index 20596f3a..540aba21 100644 --- a/mobilerun/cli/providers.py +++ b/mobilerun/cli/providers.py @@ -1,11 +1,11 @@ """Public provider spellings shared by run and configuration.""" +from __future__ import annotations + from dataclasses import dataclass import click -from mobilerun.agent.providers.registry import resolve_provider_variant - @dataclass(frozen=True) class Provider: @@ -16,6 +16,8 @@ class Provider: @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 @@ -57,3 +59,17 @@ def llm_url_kwargs(runtime, url): 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} + + +from mobilerun.cli.options import _lazy_imports # noqa: E402 + +__getattr__ = _lazy_imports(globals(), { + 'resolve_provider_variant': ('mobilerun.agent.providers.registry', 'resolve_provider_variant'), +}) + +from typing import TYPE_CHECKING # noqa: E402 + +if TYPE_CHECKING: + from mobilerun.agent.providers.registry import ( + resolve_provider_variant as resolve_provider_variant, + ) diff --git a/mobilerun/cli/qr.py b/mobilerun/cli/qr.py index 51d3a354..45ac9cb3 100644 --- a/mobilerun/cli/qr.py +++ b/mobilerun/cli/qr.py @@ -1,13 +1,15 @@ """Shared terminal QR rendering.""" +from __future__ import annotations + import sys from io import StringIO -import segno - 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 @@ -20,3 +22,15 @@ def print_qr(data: str) -> bool: except Exception: return False return True + + +from mobilerun.cli.options import _lazy_imports # noqa: E402 + +__getattr__ = _lazy_imports(globals(), { + 'segno': ('segno', None), +}) + +from typing import TYPE_CHECKING # noqa: E402 + +if TYPE_CHECKING: + import segno as segno diff --git a/mobilerun/cli/run.py b/mobilerun/cli/run.py index 7e67e89e..4b5debe5 100644 --- a/mobilerun/cli/run.py +++ b/mobilerun/cli/run.py @@ -1,21 +1,16 @@ """Run tasks with the framework agent and native async drivers.""" +from __future__ import annotations + import logging import click from async_adbutils import adb -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.utils.android_device import ( - is_portal_http_url, -) -from mobilerun.agent.utils.llm_picker import load_llm -from mobilerun.cli.event_handler import EventHandler from mobilerun.cli.options import ( DEFAULT_API_URL, Command, @@ -31,9 +26,7 @@ ) from mobilerun.cli.providers import PROVIDERS, llm_url_kwargs, provider_option from mobilerun.cli.selection import _native_driver, select_device -from mobilerun.config_manager import ConfigLoader, MobileConfig from mobilerun.log_handlers import CLILogHandler, configure_logging -from mobilerun.telemetry import print_telemetry_message console = Console(stderr=True) @@ -82,6 +75,14 @@ async def _run_task( api_url: str = DEFAULT_API_URL, ) -> 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 import MobileAgent + from mobilerun.agent.utils.llm_picker import load_llm + from mobilerun.cli.event_handler import EventHandler + from mobilerun.config_manager import ConfigLoader + from mobilerun.telemetry import print_telemetry_message + config = ConfigLoader.load(config_path) # Print cloud link in a box @@ -262,6 +263,10 @@ async def run_command(*args, **kwargs) -> bool: 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: @@ -393,3 +398,34 @@ async def run( emit({"success": success, "reason": reason, "steps": steps}, json_output=True) if not success: raise click.ClickException(reason or "Task failed") + + +from mobilerun.cli.options import _lazy_imports # noqa: E402 + +__getattr__ = _lazy_imports(globals(), { + 'VISUAL_REMOTE_CONNECTION': ('mobilerun_core_local.driver.visual_remote', 'VISUAL_REMOTE_CONNECTION'), + 'MobileAgent': ('mobilerun', 'MobileAgent'), + 'ResultEvent': ('mobilerun', 'ResultEvent'), + 'is_portal_http_url': ('mobilerun.agent.utils.android_device', 'is_portal_http_url'), + 'load_llm': ('mobilerun.agent.utils.llm_picker', 'load_llm'), + 'EventHandler': ('mobilerun.cli.event_handler', 'EventHandler'), + 'ConfigLoader': ('mobilerun.config_manager', 'ConfigLoader'), + 'MobileConfig': ('mobilerun.config_manager', 'MobileConfig'), + 'print_telemetry_message': ('mobilerun.telemetry', 'print_telemetry_message'), +}) + +from typing import TYPE_CHECKING # noqa: E402 + +if TYPE_CHECKING: + from mobilerun_core_local.driver.visual_remote import ( + VISUAL_REMOTE_CONNECTION as VISUAL_REMOTE_CONNECTION, + ) + + from mobilerun import MobileAgent as MobileAgent + from mobilerun import ResultEvent as ResultEvent + from mobilerun.agent.utils.android_device import is_portal_http_url as is_portal_http_url + from mobilerun.agent.utils.llm_picker import load_llm as load_llm + from mobilerun.cli.event_handler import EventHandler as EventHandler + from mobilerun.config_manager import ConfigLoader as ConfigLoader + from mobilerun.config_manager import MobileConfig as MobileConfig + from mobilerun.telemetry import print_telemetry_message as print_telemetry_message diff --git a/mobilerun/cli/selection.py b/mobilerun/cli/selection.py index 784fb816..f916a937 100644 --- a/mobilerun/cli/selection.py +++ b/mobilerun/cli/selection.py @@ -1,20 +1,12 @@ """Resolve CLI config and device selection before invoking a backend.""" +from __future__ import annotations + from dataclasses import dataclass, field import click -import mobilerun_core -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.cli.options import DEFAULT_API_URL -from mobilerun.config_manager import ConfigLoader, MobileConfig -from mobilerun.config_manager.config_manager import PORTAL_TOKEN_ENV_VARS @dataclass(frozen=True) @@ -43,6 +35,8 @@ def resolve_cloud_api_key(): 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 ) @@ -66,6 +60,16 @@ async def select_device( 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 @@ -108,6 +112,8 @@ def require_cloud_api_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( @@ -153,3 +159,42 @@ async def teardown_native_driver(driver): await driver.device.shell(f"ime disable {portal_ime_id(PORTAL_PACKAGE_NAME)}") except Exception: pass + + +from mobilerun.cli.options import _lazy_imports # noqa: E402 + +__getattr__ = _lazy_imports(globals(), { + 'mobilerun_core': ('mobilerun_core', None), + 'is_uuid': ('mobilerun_core.detect', 'is_uuid'), + 'resolve_backend': ('mobilerun_core.detect', 'resolve_backend'), + 'discover_ios_device': ('mobilerun_core_local.driver.ios', 'discover_ios_device'), + 'validate_ios_portal_url': ('mobilerun_core_local.driver.ios', 'validate_ios_portal_url'), + 'AndroidDeviceSelectionError': ('mobilerun.agent.utils.android_device', 'AndroidDeviceSelectionError'), + 'is_portal_http_url': ('mobilerun.agent.utils.android_device', 'is_portal_http_url'), + 'resolve_android_serial': ('mobilerun.agent.utils.android_device', 'resolve_android_serial'), + 'ConfigLoader': ('mobilerun.config_manager', 'ConfigLoader'), + 'MobileConfig': ('mobilerun.config_manager', 'MobileConfig'), + 'PORTAL_TOKEN_ENV_VARS': ('mobilerun.config_manager.config_manager', 'PORTAL_TOKEN_ENV_VARS'), +}) + +from typing import TYPE_CHECKING # noqa: E402 + +if TYPE_CHECKING: + import mobilerun_core as mobilerun_core + from mobilerun_core.detect import is_uuid as is_uuid + from mobilerun_core.detect import resolve_backend as resolve_backend + from mobilerun_core_local.driver.ios import discover_ios_device as discover_ios_device + from mobilerun_core_local.driver.ios import validate_ios_portal_url as validate_ios_portal_url + + from mobilerun.agent.utils.android_device import ( + AndroidDeviceSelectionError as AndroidDeviceSelectionError, + ) + from mobilerun.agent.utils.android_device import is_portal_http_url as is_portal_http_url + from mobilerun.agent.utils.android_device import ( + resolve_android_serial as resolve_android_serial, + ) + from mobilerun.config_manager import ConfigLoader as ConfigLoader + from mobilerun.config_manager import MobileConfig as MobileConfig + from mobilerun.config_manager.config_manager import ( + PORTAL_TOKEN_ENV_VARS as PORTAL_TOKEN_ENV_VARS, + ) diff --git a/mobilerun/config_manager/__init__.py b/mobilerun/config_manager/__init__.py index fc01cf3a..bae3271b 100644 --- a/mobilerun/config_manager/__init__.py +++ b/mobilerun/config_manager/__init__.py @@ -1,22 +1,47 @@ -from mobilerun.config_manager.config_manager import ( - AgentConfig, - AppCardConfig, - CredentialsConfig, - 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, + 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'), + '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", @@ -38,3 +63,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/macro/cli.py b/mobilerun/macro/cli.py index c72469d5..2fda26fb 100644 --- a/mobilerun/macro/cli.py +++ b/mobilerun/macro/cli.py @@ -2,21 +2,21 @@ Command-line interface for Mobilerun macro replay. """ +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.trajectory import Trajectory from mobilerun.cli.options import ( - examples, Group, coro, device_options, emit, emit_rows, + examples, json_option, max_steps_option, model_option, @@ -26,7 +26,6 @@ from mobilerun.cli.providers import PROVIDERS, provider_option from mobilerun.cli.selection import _native_driver, select_device, teardown_native_driver from mobilerun.config_manager.path_resolver import PathResolver -from mobilerun.macro.replay import MacroPlayer def configure_logging(debug: bool = False): @@ -154,6 +153,8 @@ async def _replay_async( ): """Async function to handle macro replay.""" # 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(): @@ -234,6 +235,8 @@ 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 @@ -293,6 +296,8 @@ async def _show_dry_run( @json_option def list(directory: str, verbose: bool, json_output: bool): """List available trajectory folders in a directory.""" + from mobilerun.agent.utils.trajectory import Trajectory + logger = configure_logging(bool(verbose)) # Resolve directory (checks working dir, then package dir) @@ -349,3 +354,20 @@ def console_render(table): if __name__ == "__main__": macro_cli() + + +from mobilerun.cli.options import _lazy_imports # noqa: E402 + +__getattr__ = _lazy_imports(globals(), { + 'Table': ('rich.table', 'Table'), + 'Trajectory': ('mobilerun.agent.utils.trajectory', 'Trajectory'), + 'MacroPlayer': ('mobilerun.macro.replay', 'MacroPlayer'), +}) + +from typing import TYPE_CHECKING # noqa: E402 + +if TYPE_CHECKING: + from rich.table import Table as Table + + from mobilerun.agent.utils.trajectory import Trajectory as Trajectory + from mobilerun.macro.replay import MacroPlayer as MacroPlayer diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index 94af0a34..63902712 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -12,7 +12,7 @@ import pytest from click.testing import CliRunner -from mobilerun.cli import auth, device_lifecycle, deviceauth, selection +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 @@ -153,7 +153,7 @@ def test_env_sources_and_secret_config(monkeypatch, tmp_path): def test_cloud_target_without_device_does_not_touch_adb(monkeypatch): - monkeypatch.setattr(selection, "resolve_android_serial", AsyncMock(side_effect=AssertionError("must not discover"))) + 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 @@ -187,9 +187,8 @@ def test_macro_list_json(tmp_path): def test_doctor_failure_json(monkeypatch): - doctor = importlib.import_module("mobilerun.cli.doctor_command") from mobilerun.cli.doctor import CheckResult, Status - monkeypatch.setattr(doctor, "run_doctor", AsyncMock(return_value=[CheckResult("Device", Status.FAIL, "missing device")])) + 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) == { @@ -259,7 +258,7 @@ def run(self): @pytest.mark.parametrize("success", [True, False]) def test_run_json_is_built_from_result_event(monkeypatch, success): - monkeypatch.setattr(run_module, "MobileAgent", fake_agent(SimpleNamespace(success=success, reason="observed result", steps=4))) + 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} @@ -277,7 +276,7 @@ def __init__(self, **kwargs): async def connect(self): pass monkeypatch.setattr(cloud, "CloudDriver", Driver) - monkeypatch.setattr(run_module, "MobileAgent", fake_agent(SimpleNamespace(success=True, reason="done", steps=1), calls)) + 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"]) @@ -329,7 +328,7 @@ def load_macro_from_file(self, path): async def replay_macro(self, *args, **kwargs): return False - monkeypatch.setattr(macro, "MacroPlayer", Player) + 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)]) @@ -398,7 +397,7 @@ def test_config_provider_url_never_crashes_with_stop_iteration(monkeypatch, runt for profile in config.llm_profiles.values(): profile.provider = runtime monkeypatch.setattr(run_module.ConfigLoader, "load", lambda _: config) - monkeypatch.setattr(run_module, "MobileAgent", fake_agent(SimpleNamespace(success=True, reason="done", steps=1))) + 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: @@ -414,8 +413,8 @@ def test_config_provider_url_never_crashes_with_stop_iteration(monkeypatch, runt def test_run_only_providers_work_and_configure_rejects_them(monkeypatch, provider): llm = object() load = Mock(return_value=llm) - monkeypatch.setattr(run_module, "load_llm", load) - monkeypatch.setattr(run_module, "MobileAgent", fake_agent(SimpleNamespace(success=True, reason="done", steps=1))) + 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,) @@ -554,7 +553,7 @@ async def device_check(serial, debug): def test_configure_api_provider_preselects_api_key_and_validates_auth_mode(monkeypatch): configure = importlib.import_module("mobilerun.cli.configure") wizard = Mock() - monkeypatch.setattr(configure, "run_configure_wizard", wizard) + 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 @@ -569,7 +568,6 @@ def test_run_help_task_and_agent_validation(): def test_macro_replay_unexpected_error_printed_once_with_traceback(monkeypatch, tmp_path): - macro = importlib.import_module("mobilerun.macro.cli") class Player: def __init__(self, **kwargs): pass @@ -577,7 +575,7 @@ def load_macro_from_file(self, path): raise RuntimeError("unique macro failure") path = tmp_path / "macro.json" path.write_text("{}") - monkeypatch.setattr(macro, "MacroPlayer", Player) + 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 diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index e78d8d46..f93ec075 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -62,13 +62,13 @@ def patch(tty=True): def secret_command(request, monkeypatch, tmp_path): callback = Mock() if request.param == "configure": - monkeypatch.setattr(configure, "run_configure_wizard", callback) + 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(auth, "save_anthropic_setup_token", callback) + monkeypatch.setattr("mobilerun.cli.oauth_actions.save_anthropic_setup_token", callback) args = [ "auth", "login", @@ -226,7 +226,7 @@ async def check(*args, **kwargs): console.print("Please enable accessibility manually") return [CheckResult("Device", Status.FAIL if failed else Status.PASS, "observed", "detail")] - monkeypatch.setattr(doctor_command, "run_doctor", check) + 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 @@ -492,7 +492,7 @@ def test_configure_without_input_fails_before_loading_or_writing( ConfigLoader, "load", Mock(side_effect=AssertionError("must not load or create config")) ) wizard = Mock(side_effect=AssertionError("must not prompt")) - monkeypatch.setattr(configure, "run_configure_wizard", wizard) + 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 @@ -555,7 +555,7 @@ def fail(*args, **kwargs): ) def test_login_only_blocks_flows_that_need_terminal_input(monkeypatch, service, code, calls, hints): - monkeypatch.setattr(auth, "login_requires_input", lambda name: name in {"anthropic", "gemini"}) + monkeypatch.setattr("mobilerun.cli.oauth_actions.login_requires_input", lambda name: name in {"anthropic", "gemini"}) callbacks = [] for name in ( "_login_mobilerun", @@ -577,7 +577,7 @@ def test_login_only_blocks_flows_that_need_terminal_input(monkeypatch, service, @pytest.mark.parametrize("service", ["anthropic", "gemini"]) def test_provider_callback_login_without_manual_prompt_is_allowed(monkeypatch, service): - monkeypatch.setattr(auth, "login_requires_input", lambda _: False) + 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"]) @@ -587,7 +587,7 @@ def test_provider_callback_login_without_manual_prompt_is_allowed(monkeypatch, s @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(configure, "run_configure_wizard", Mock(side_effect=failure)) + 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: @@ -601,7 +601,7 @@ def prompt(*args, **kwargs): ) return click.prompt("Value") - monkeypatch.setattr(configure, "run_configure_wizard", prompt) + 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 @@ -610,7 +610,7 @@ def prompt(*args, **kwargs): def test_no_input_configure_resolves_provider_environment_key(monkeypatch): monkeypatch.setenv("OPENAI_API_KEY", "dummy-environment-marker") wizard = Mock() - monkeypatch.setattr(configure, "run_configure_wizard", wizard) + 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 @@ -618,9 +618,9 @@ def test_no_input_configure_resolves_provider_environment_key(monkeypatch): def test_no_input_configure_never_starts_missing_oauth_login(monkeypatch): - monkeypatch.setattr(configure, "_oauth_credentials_present", lambda *args: False) + monkeypatch.setattr("mobilerun.cli.configure_wizard._oauth_credentials_present", lambda *args: False) wizard = Mock() - monkeypatch.setattr(configure, "run_configure_wizard", wizard) + monkeypatch.setattr("mobilerun.cli.configure_wizard.run_configure_wizard", wizard) result = CliRunner().invoke( cli, ["configure", "-p", "openai-oauth", "-m", "MODEL", "--no-input"] ) @@ -1100,7 +1100,7 @@ def cloud_status(monkeypatch, tmp_path, cloud_credential_case): monkeypatch.setattr( deviceauth, "fetch_session", Mock(return_value={"user": {"email": "user@example.test"}}) ) - monkeypatch.setattr(auth, "_oauth_credentials_present", lambda *a: False) + 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): diff --git a/tests/test_cli_polish_fix2.py b/tests/test_cli_polish_fix2.py index 24684f84..8843b4e4 100644 --- a/tests/test_cli_polish_fix2.py +++ b/tests/test_cli_polish_fix2.py @@ -120,7 +120,7 @@ async def done(): return done().__await__() agent = Mock(return_value=SimpleNamespace(run=lambda: Handler())) - monkeypatch.setattr(run_module, "MobileAgent", agent) + 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", diff --git a/tests/test_cli_startup.py b/tests/test_cli_startup.py new file mode 100644 index 00000000..90e4a6b4 --- /dev/null +++ b/tests/test_cli_startup.py @@ -0,0 +1,181 @@ +"""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", + "ResultEvent", + "DeviceDriver", + "AndroidDriver", + "RecordingDriver", + "MacroPlayer", + "replay_macro_file", + "replay_macro_folder", + "MobileConfig", + "AgentConfig", + "FastAgentConfig", + "ManagerConfig", + "ExecutorConfig", + "AppCardConfig", + "DeviceConfig", + "LoggingConfig", + "TracingConfig", + "TelemetryConfig", + "ToolsConfig", + "CredentialsConfig", + "LLMProfile", + ], + "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", + "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", + "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_ios_local_portal_wiring.py b/tests/test_ios_local_portal_wiring.py index 0cd9747e..984dba0a 100644 --- a/tests/test_ios_local_portal_wiring.py +++ b/tests/test_ios_local_portal_wiring.py @@ -148,7 +148,7 @@ def connect(self, device_id, **kwargs): def test_device_commands_discover_when_no_device(tmp_path, monkeypatch): from mobilerun_core import Mobilerun - from mobilerun.cli import device_commands, selection + from mobilerun.cli import device_commands created = {} async def discover(): @@ -156,7 +156,7 @@ async def discover(): def connect(self, device_id, **kwargs): created.update(kwargs) return object() - monkeypatch.setattr(selection, "discover_ios_device", discover) + 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") diff --git a/tests/test_keyboard_cleanup.py b/tests/test_keyboard_cleanup.py index 51c50bc6..204db43b 100644 --- a/tests/test_keyboard_cleanup.py +++ b/tests/test_keyboard_cleanup.py @@ -46,9 +46,9 @@ def _serials(devices): def _run_cli(monkeypatch, config, agent_factory, **kwargs): monkeypatch.setattr(cli_main.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)) diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 8878dd8b..e8f81266 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -356,7 +356,7 @@ async def skip_keyboard_cleanup(config, serial=None) -> None: monkeypatch.setattr(cli_main.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) diff --git a/tests/test_vision_only_run_cli.py b/tests/test_vision_only_run_cli.py index 3bfbf916..5afde851 100644 --- a/tests/test_vision_only_run_cli.py +++ b/tests/test_vision_only_run_cli.py @@ -105,8 +105,8 @@ def run(self): "mobilerun.cli.run.ConfigLoader.load", return_value=MobileConfig(), ), - patch("mobilerun.cli.run.MobileAgent", FakeAgent), - patch("mobilerun.cli.selection.discover_ios_device", AsyncMock()) as discover, + patch("mobilerun.MobileAgent", FakeAgent), + patch("mobilerun_core_local.driver.ios.discover_ios_device", AsyncMock()) as discover, ): success = asyncio.run( run_command( @@ -158,7 +158,7 @@ def run(self): "mobilerun.cli.run.ConfigLoader.load", return_value=MobileConfig(), ), - patch("mobilerun.cli.run.MobileAgent", FakeAgent), + patch("mobilerun.MobileAgent", FakeAgent), ): success = asyncio.run( run_command( @@ -211,7 +211,7 @@ def run(self): with ( patch("mobilerun.cli.run.ConfigLoader.load", return_value=config), - patch("mobilerun.cli.run.MobileAgent", FakeAgent), + patch("mobilerun.MobileAgent", FakeAgent), patch("mobilerun.cli.run.adb.device", AsyncMock()) as adb_device, ): success = asyncio.run(run_command("Check Wi-Fi", debug=False)) From 2e66a24cb61d76d857ffe514c176b8a5454aa33c Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 22:22:12 +0100 Subject: [PATCH 33/59] fix(cli): address runtime findings --- README.md | 10 +- mobilerun/__init__.py | 54 ++++--- mobilerun/cli/auth.py | 11 +- mobilerun/cli/completion.py | 5 +- mobilerun/cli/device_commands.py | 3 + mobilerun/cli/device_lifecycle.py | 5 + mobilerun/cli/device_pair.py | 5 +- mobilerun/cli/doctor.py | 15 +- mobilerun/cli/env.py | 4 + mobilerun/cli/main.py | 129 ++++++++++------ mobilerun/cli/options.py | 111 +++++++++++++- mobilerun/cli/selection.py | 6 + tests/test_cli_contract.py | 1 + tests/test_cli_devx.py | 1 + tests/test_cli_exit_codes.py | 1 + tests/test_cli_fix4.py | 244 ++++++++++++++++++++++++++++++ tests/test_cli_polish_fix2.py | 1 + tests/test_cli_qr.py | 9 +- 18 files changed, 509 insertions(+), 106 deletions(-) create mode 100644 tests/test_cli_fix4.py diff --git a/README.md b/README.md index 40fa89f8..a32c6344 100644 --- a/README.md +++ b/README.md @@ -288,13 +288,17 @@ 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":"Device not found","status":404,"code":null} +{"error":"Resource not found","status":404,"code":null} ``` -`doctor -j` retains completed checks, including when it exits 1: +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":"Device","status":"fail","detail":"no device connected\nConnect a device over USB or run `mobilerun device pair`; use `-t cloud` for cloud devices."}]} +{"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 diff --git a/mobilerun/__init__.py b/mobilerun/__init__.py index 5f15ce5b..4c27e328 100644 --- a/mobilerun/__init__.py +++ b/mobilerun/__init__.py @@ -2,31 +2,35 @@ Mobilerun - A framework for controlling Android devices through LLM agents. """ -import logging -from importlib import import_module -from importlib.metadata import version -from typing import TYPE_CHECKING - -__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 - -from mobilerun.agent.utils.errors import HtmlErrorPageFilter # noqa: E402 - -# llama-index's retry warnings include provider error bodies verbatim. -for _retry_logger in ( - "llama_index.llms.openai.utils", - "llama_index.llms.google_genai.utils", -): - logging.getLogger(_retry_logger).addFilter(HtmlErrorPageFilter()) +try: + import logging + from importlib import import_module + from importlib.metadata import version + from typing import TYPE_CHECKING + + __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 + + from mobilerun.agent.utils.errors import HtmlErrorPageFilter # noqa: E402 + + # llama-index's retry warnings include provider error bodies verbatim. + for _retry_logger in ( + "llama_index.llms.openai.utils", + "llama_index.llms.google_genai.utils", + ): + logging.getLogger(_retry_logger).addFilter(HtmlErrorPageFilter()) + +except KeyboardInterrupt: + raise SystemExit(130) from None diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py index c5c6ba06..2d141c8e 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -96,7 +96,7 @@ def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool): try: code = deviceauth.request_code(auth_url, client_id) except Exception as e: - raise click.ClickException(f"request device code: {e}") from e + raise click.ClickException(f"request device code: {e}\nCheck --auth-url or MOBILERUN_AUTH_URL: {auth_url}") from e console.print(f"Visit: [bold]{code.verification_uri}[/]") console.print(f"Code: [bold cyan]{code.user_code}[/]") @@ -311,6 +311,8 @@ def logout(service, verbose): 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" and resolve_cloud_credential().source == "env": + console.print("MOBILERUN_CLOUD_API_KEY stays active; unset it to stop using the env key.") @auth_cli.command() @@ -366,7 +368,12 @@ def status(verbose, json_output): email = f" ({row['email']})" if row.get("email") else "" cloud = "" if row["service"] == "mobilerun": - cloud = f"; cloud commands use {row['cloud_credential']}" + cloud = ( + "; cloud commands have no credential; run `mobilerun auth login` " + "or set MOBILERUN_CLOUD_API_KEY" + if row["cloud_credential"] == "not set" + else f"; cloud commands use {row['cloud_credential']}" + ) if row["env_overrides_login"]: cloud += " (overrides saved sign-in)" emit(f"{row['service']}: {label}{email} {row['credential_path']}{cloud}") diff --git a/mobilerun/cli/completion.py b/mobilerun/cli/completion.py index 9a63106f..c74b6757 100644 --- a/mobilerun/cli/completion.py +++ b/mobilerun/cli/completion.py @@ -3,12 +3,13 @@ import click from click.shell_completion import get_completion_class -from mobilerun.cli.options import Command, emit +from mobilerun.cli.options import Command, emit, verbose_option @click.command(cls=Command) @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"])) -def completion(shell): +@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") diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index fa82f709..33590e87 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -11,6 +11,7 @@ import click +from mobilerun.cli import options from mobilerun.cli.options import ( Group, _ui_cell, @@ -37,6 +38,8 @@ def device_driver(params=None, *, selection=None): device=params.get("device"), config_path=params.get("config_path"), platform=params.get("platform"), target=params.get("target", "local"), )) + if selection.backend == "local-android-adb": + options.require_adb() token = portal_token(selection) client = core_client(params["api_url"], cloud=selection.backend == "cloud") try: diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index f58f7dad..3513cba7 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -5,6 +5,7 @@ import click from async_adbutils import adb +from mobilerun.cli import options from mobilerun.cli.device_commands import device_cli, probe_device from mobilerun.cli.device_pair import adb_connect_succeeded from mobilerun.cli.options import ( @@ -67,6 +68,8 @@ def list_devices(target, api_url, verbose, json_output, all_devices): def _list_target(api_url, scope): + if scope == "local": + options.require_adb() client = core_client(api_url, cloud=scope == "cloud") devices = [] try: @@ -96,6 +99,7 @@ def _list_target(api_url, scope): @coro async def connect(serial, verbose): """Connect to an Android device over TCP/IP.""" + options.require_adb() result = await adb.connect(serial) if not adb_connect_succeeded(result): raise click.ClickException(f"Failed to connect to {serial}: {result}") @@ -108,6 +112,7 @@ async def connect(serial, verbose): @coro async def disconnect(serial, verbose): """Disconnect an Android TCP/IP device.""" + options.require_adb() success = await adb.disconnect(serial, raise_error=True) click.echo( f"Successfully disconnected from {serial}" diff --git a/mobilerun/cli/device_pair.py b/mobilerun/cli/device_pair.py index faec4ad7..b5089a97 100644 --- a/mobilerun/cli/device_pair.py +++ b/mobilerun/cli/device_pair.py @@ -2,7 +2,6 @@ import re import secrets -import shutil import string import subprocess import sys @@ -12,6 +11,7 @@ import click +from mobilerun.cli import options from mobilerun.cli.device_commands import device_cli from mobilerun.cli.options import emit, examples, verbose_option from mobilerun.cli.qr import print_qr @@ -165,8 +165,7 @@ 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.") - if not shutil.which("adb"): - raise click.ClickException("adb is missing; install Android SDK Platform-Tools.") + options.require_adb() deadline = time.monotonic() + timeout check = _adb("mdns", "check", deadline=deadline) if "mdns daemon version" not in check.stdout.lower(): diff --git a/mobilerun/cli/doctor.py b/mobilerun/cli/doctor.py index 85c98ddf..895e9381 100644 --- a/mobilerun/cli/doctor.py +++ b/mobilerun/cli/doctor.py @@ -3,7 +3,6 @@ import asyncio import json import re -import shutil from dataclasses import dataclass from enum import Enum from typing import Any @@ -25,6 +24,7 @@ from rich.console import Console from mobilerun import __version__ +from mobilerun.cli import options console = Console(stderr=True) @@ -165,16 +165,9 @@ async def check_config(debug: bool, config_path: str | None = None) -> tuple[Che 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", - ) - + adb_path = "" try: + adb_path = options.require_adb() devices = await adb.list() count = len(devices) return CheckResult( @@ -187,7 +180,7 @@ async def check_adb(debug: bool) -> CheckResult: return CheckResult( "ADB", Status.FAIL, - f"adb found but server error: {e}", + e.message if isinstance(e, options.click.ClickException) else f"adb found but server error: {e}", detail=adb_path, ) diff --git a/mobilerun/cli/env.py b/mobilerun/cli/env.py index ca911b5b..31e3f008 100644 --- a/mobilerun/cli/env.py +++ b/mobilerun/cli/env.py @@ -79,6 +79,10 @@ def resolved_environment(config_path=None): if (name != "MOBILERUN_CLOUD_API_KEY" and os.environ.get(name) and not (name == "MOBILERUN_CONFIG" and path_source == "flag")): value, source = os.environ[name], "env" + if (name == "MOBILERUN_DEBUG" and ctx and + any(parent.get_parameter_source("verbose") == click.core.ParameterSource.COMMANDLINE + for parent in (ctx, ctx.find_root()))): + value, source = ctx.params.get("verbose"), "flag" if name == "MOBILERUN_TELEMETRY_ENABLED": value = is_telemetry_enabled(config_enabled=config.telemetry.enabled) if name in BOOLEAN_VARIABLES and isinstance(value, str): diff --git a/mobilerun/cli/main.py b/mobilerun/cli/main.py index 2834140f..ce1006b3 100644 --- a/mobilerun/cli/main.py +++ b/mobilerun/cli/main.py @@ -1,50 +1,83 @@ """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) +try: + 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) + + _EXAMPLES = { + "device screenshot": "mobilerun device screenshot -d emulator-5554", + "device tap": "mobilerun device tap 100 200", + "device swipe": "mobilerun device swipe 100 500 100 100", + "device long-press": "mobilerun device long-press 100 200", + "device type": 'mobilerun device type "Hello"', + "device press": "mobilerun device press home", + "device apps": "mobilerun device apps -j", + "device start": "mobilerun device start com.android.settings", + "device connect": "mobilerun device connect 192.0.2.1:5555", + "device disconnect": "mobilerun device disconnect 192.0.2.1:5555", + "device setup": "mobilerun device setup -d emulator-5554", + "device ping": "mobilerun device ping -d emulator-5554", + "auth": "mobilerun auth status", + "auth status": "mobilerun auth status -j", + "auth logout": "mobilerun auth logout", + "doctor": "mobilerun doctor -j", + "env": "mobilerun env -j", + "macro": "mobilerun macro list ./macros", + "macro list": "mobilerun macro list ./macros", + "completion": "mobilerun completion bash", + } + for _path, _example in _EXAMPLES.items(): + _command = cli + for _name in _path.split(): + _command = _command.commands[_name] + if not _command.epilog: + _command.epilog = examples(_example) + +except KeyboardInterrupt: + raise SystemExit(130) from None diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index 79e48af4..8cca072a 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -7,6 +7,8 @@ import json import logging import os +import shutil +import socket import sys import traceback import unicodedata @@ -27,6 +29,41 @@ 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 require_adb(): + """Check local tooling without invoking the library's binary fallback.""" + path = shutil.which("adb") + try: + if not path: + raise OSError("missing adb") + with socket.create_connection(( + os.environ.get("ANDROID_ADB_SERVER_HOST", "127.0.0.1"), + int(os.environ.get("ANDROID_ADB_SERVER_PORT", "5037")), + ), timeout=1): + pass + except (OSError, ValueError): + raise click.ClickException(ADB_HINT) from None + return path + + +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) + return data.get("logging", {}).get("debug") is True if isinstance(data, dict) else False + except (OSError, ValueError, AttributeError, yaml.YAMLError): + return False + + def local_device_hint(message, target="local"): """Add the recovery step for known local discovery errors.""" lowered = message.lower() @@ -46,9 +83,16 @@ def read_secret(ctx, param, value): return value if value == "-": stream = click.get_text_stream("stdin") - 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") + 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 isinstance(exc, KeyboardInterrupt) or isinstance( + exc.__cause__ or exc.__context__, KeyboardInterrupt + ): + raise click.exceptions.Exit(130) from None + raise click.ClickException("Input closed; run the command again in a terminal.") from None if not value.strip(): raise click.BadParameter("stdin is empty; provide one secret line.", ctx=ctx, param=param) if value: @@ -57,7 +101,7 @@ def read_secret(ctx, param, value): def redact(text, secrets): - for secret in sorted(set(filter(None, secrets)), key=len, reverse=True): + for secret in sorted({value for value in secrets if value and len(value) >= 8}, key=len, reverse=True): text = text.replace(secret, "[redacted]") return text @@ -163,6 +207,26 @@ def __init__(self, *args, **kwargs): kwargs.setdefault("context_settings", CONTEXT_SETTINGS) super().__init__(*args, **kwargs) + def parse_args(self, ctx, args): + if not ctx.resilient_parsing: + for arg in args: + if arg == "--": + break + flag = arg.split("=", 1)[0] + replacement = _REMOVED_FLAGS.get(flag) + if flag == "--path" and self.name == "setup": + replacement = "--apk" + if replacement: + raise click.UsageError( + f"Removed option: {flag}. Use {replacement} instead.", ctx + ) + try: + return super().parse_args(ctx, args) + except click.NoSuchOption as exc: + if self.name in {"tap", "type", "swipe", "long-press"}: + exc.message += " Put `--` before arguments that start with a dash." + raise + def get_params(self, ctx): def rank(param): order = ("device", "target", "platform", "config_path", None, @@ -193,14 +257,27 @@ def invoke(self, ctx): previous = logger.handlers[:], logger.level, logger.propagate handler = logging.StreamHandler(sys.stderr) handler.setFormatter(SecretFormatter(ctx)) - configure_logging(bool(ctx.params.get("verbose")), handler) try: # Libraries and OAuth flows may print progress directly. with redirect_stdout(sys.stderr): - if ctx.command.name not in {"doctor", "completion"}: + if ctx.command.name != "completion": from mobilerun.config_manager import ConfigLoader - ConfigLoader.resolve_path(ctx.params.get("config_path")) + try: + path = ConfigLoader.resolve_path(ctx.params.get("config_path")) + except (OSError, ValueError): + if ctx.command.name != "doctor": + raise + path = ConfigLoader.get_user_config_path() + else: + from pathlib import Path + + from mobilerun.config_manager.credential_paths import USER_CONFIG_DIR + + path = Path(os.environ.get("MOBILERUN_CONFIG", USER_CONFIG_DIR / "config.yaml")) + 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) @@ -235,6 +312,13 @@ def invoke(self, ctx): logger.setLevel(level) +_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", +} + _RENAMED_COMMANDS = { "devices": "mobilerun device list", "whoami": "mobilerun auth status", @@ -273,7 +357,18 @@ def resolve_command(self, ctx, args): 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"'{match}'" for match in matches) + paths = {" ".join(path): command for path, command in + _command_paths(ctx.find_root().command)} + suggestions = [] + for match in matches: + command = paths.get(match) + for remaining in args[1:]: + if not isinstance(command, click.Group) or remaining not in command.commands: + break + match += " " + remaining + command = command.commands[remaining] + suggestions.append(f"'{match}'") + suggestions = ", ".join(suggestions) exc.message += f" Did you mean {suggestions}?" raise diff --git a/mobilerun/cli/selection.py b/mobilerun/cli/selection.py index f916a937..d0d80f1d 100644 --- a/mobilerun/cli/selection.py +++ b/mobilerun/cli/selection.py @@ -6,6 +6,7 @@ import click +from mobilerun.cli import options from mobilerun.cli.options import DEFAULT_API_URL @@ -81,10 +82,12 @@ async def select_device( "Cloud device required: pass -d ; find ids with `mobilerun device list -t cloud`." ) if not discover and selected_platform != "ios": + options.require_adb() return Selection(config, None, selected_platform, "local-android-adb") if selected_platform == "ios": selected = await discover_ios_device() else: + options.require_adb() try: selected = await resolve_android_serial() except AndroidDeviceSelectionError as exc: @@ -99,6 +102,8 @@ async def select_device( url=url, backend="local-android-adb" if not url and not is_uuid(selected) else None, ) + if backend == "local-android-adb": + options.require_adb() return Selection(config, selected, selected_platform, backend) @@ -140,6 +145,7 @@ async def _native_driver(selection, config, api_url): elif selection.backend == "local-android-http": driver = create_android_portal_http_driver(selection.device, portal_token(selection)) else: + options.require_adb() 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)) diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index 63902712..8ab18f3d 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -29,6 +29,7 @@ def commands(group=cli, path=()): @pytest.fixture(autouse=True) def isolated_config(monkeypatch, tmp_path): + monkeypatch.setattr("mobilerun.cli.options.require_adb", lambda: "/fake/adb") 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") diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index f93ec075..4734533a 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -43,6 +43,7 @@ def commands(command=cli, path=()): @pytest.fixture(autouse=True) def isolated_config(monkeypatch, tmp_path): + monkeypatch.setattr("mobilerun.cli.options.require_adb", lambda: "/fake/adb") 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"): diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py index d052312b..d592f251 100644 --- a/tests/test_cli_exit_codes.py +++ b/tests/test_cli_exit_codes.py @@ -12,6 +12,7 @@ @pytest.fixture(autouse=True) def config(monkeypatch): + monkeypatch.setattr("mobilerun.cli.options.require_adb", lambda: "/fake/adb") monkeypatch.setattr(selection.ConfigLoader, "load", lambda _: MobileConfig()) diff --git a/tests/test_cli_fix4.py b/tests/test_cli_fix4.py new file mode 100644 index 00000000..106f2456 --- /dev/null +++ b/tests/test_cli_fix4.py @@ -0,0 +1,244 @@ +"""Literal regressions from the independent CLI runtime verification.""" + +import importlib +import json +import logging +import os +import select +import signal +import socket +import subprocess +import sys +from contextlib import contextmanager, nullcontext +from pathlib import Path +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("available", [False, True]) +@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, tmp_path, available, args): + # Hold an unused port without listening: connection deterministically fails. + with socket.socket() as reserved: + reserved.bind(("127.0.0.1", 0)) + monkeypatch.setenv("ANDROID_ADB_SERVER_HOST", "127.0.0.1") + monkeypatch.setenv("ANDROID_ADB_SERVER_PORT", str(reserved.getsockname()[1])) + if available: + binary = tmp_path / "adb" + binary.write_text("#!/bin/sh\nexit 99\n") + binary.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path)) + result = CliRunner().invoke(cli, args) + assert result.exit_code == 1, result.output + assert result.stdout == "" + assert result.stderr == "Error: " + options.ADB_HINT + "\n" + assert "adbutils" not in result.output + + +def test_all_keeps_cloud_when_adb_fails(monkeypatch): + monkeypatch.setenv("PATH", "") + row = type("Device", (), {"device_id": "cloud-id", "_conn": type("Conn", (), { + "_device_cache": type("Metadata", (), {"name": "phone", "state": "running", "type": "android"})() + })()})() + monkeypatch.setattr(device_lifecycle, "core_client", lambda *a, **k: type("Client", (), { + "list_devices": lambda *a, **k: [row] + })()) + 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", ["rich.console", "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_CLOUD_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_every_command_examples_and_verbose(): + def visit(command, path=()): + yield command, path + if isinstance(command, click.Group): + for name, child in command.commands.items(): + yield from visit(child, (*path, name)) + + for _, path in visit(cli): + result = CliRunner().invoke(cli, [*path, "--help"]) + assert result.exit_code == 0 + assert "Examples:" in result.stdout, path + assert "--verbose" in result.stdout, path + + +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_and_pair_share_adb_check(monkeypatch): + doctor = importlib.import_module("mobilerun.cli.doctor") + from mobilerun.cli import device_pair + + monkeypatch.setattr(options.shutil, "which", lambda _: "/fake/adb") + monkeypatch.setattr(options.socket, "create_connection", Mock(return_value=nullcontext())) + check = Mock(return_value="/fake/adb") + monkeypatch.setattr(options, "require_adb", check) + monkeypatch.setattr(doctor.adb, "list", AsyncMock(return_value=[])) + import asyncio + + result = asyncio.run(doctor.check_adb(False)) + assert result.message == "found, 0 device(s)" + check.assert_called_once() + monkeypatch.setattr(device_pair.sys.stderr, "isatty", lambda: True) + monkeypatch.setattr(device_pair, "_adb", Mock(side_effect=click.ClickException("mdns stop"))) + with pytest.raises(click.ClickException, match="mdns stop"): + device_pair.pair.callback(timeout=1, verbose=False) + assert check.call_count == 2 diff --git a/tests/test_cli_polish_fix2.py b/tests/test_cli_polish_fix2.py index 8843b4e4..eebd4ca6 100644 --- a/tests/test_cli_polish_fix2.py +++ b/tests/test_cli_polish_fix2.py @@ -20,6 +20,7 @@ @pytest.fixture(autouse=True) def isolated_config(monkeypatch, tmp_path): + monkeypatch.setattr("mobilerun.cli.options.require_adb", lambda: "/fake/adb") monkeypatch.setattr(ConfigLoader, "get_user_config_path", lambda: tmp_path / "config.yaml") for name in ( "MOBILERUN_ANDROID_PORTAL_TOKEN", "MOBILERUN_IOS_PORTAL_TOKEN", diff --git a/tests/test_cli_qr.py b/tests/test_cli_qr.py index 633b8d9f..0d034e95 100644 --- a/tests/test_cli_qr.py +++ b/tests/test_cli_qr.py @@ -3,7 +3,7 @@ import io import subprocess import sys -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -178,7 +178,8 @@ def test_mdns_parser_skips_unsafe_addresses(address): @pytest.fixture def pairing(monkeypatch): - monkeypatch.setattr(device_pair.shutil, "which", lambda _: "/fake/adb") + monkeypatch.setattr(device_pair.options.shutil, "which", lambda _: "/fake/adb") + monkeypatch.setattr(device_pair.options.socket, "create_connection", Mock(return_value=nullcontext())) printed = Mock(return_value=True) monkeypatch.setattr(device_pair, "print_qr", printed) clock = SimpleNamespace(now=0.0) @@ -444,10 +445,10 @@ def test_pair_bounds_adb_calls_by_timeout(monkeypatch, pairing): def test_pair_requires_adb(monkeypatch, pairing): - monkeypatch.setattr(device_pair.shutil, "which", lambda _: None) + monkeypatch.setattr(device_pair.options.shutil, "which", lambda _: None) result = TerminalRunner().invoke(cli, ["device", "pair"]) assert result.exit_code == 1 - assert "adb is missing" in result.stderr + assert "adb was not found or is not running" in result.stderr assert not pairing.calls From f7a8e251b1828fa74fa697885ec5802fa0c9bc66 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Wed, 11 Nov 2026 22:26:16 +0100 Subject: [PATCH 34/59] fix(cli): make browser opt-in --- README.md | 12 ++++-- mobilerun/cli/auth.py | 33 ++++++++++------ mobilerun/cli/qr.py | 4 +- tests/test_cli_qr.py | 91 ++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 119 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index a32c6344..4d81b620 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,13 @@ mobilerun auth login mobilerun device list -t cloud ``` -Sign-in prints a link, a user code, and a QR code when stderr is a terminal. -Scan the QR or use the link and code to approve access. This cloud sign-in is -separate from the LLM provider configured above. +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. Set the target and default device, replacing the placeholder with the listed id: @@ -230,6 +234,8 @@ screenshot, ui, apps, start, and ping. | — | `--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-qr` | Mobilerun login: suppress the QR code. | | — | `--no-input` | Configure and auth login: never prompt for input. | diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py index 2d141c8e..79f81a15 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -3,6 +3,8 @@ from __future__ import annotations import math +import os +import sys from pathlib import Path import click @@ -89,7 +91,7 @@ def _run_grok_oauth_login(credential_path: str, model: str | None = None, **kwar DEFAULT_DEVICE_CLIENT_ID = "mobilerun-cli" -def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool): +def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool, browser: bool = False): """Authenticate via OAuth 2.0 device authorization and save the session token.""" import webbrowser @@ -98,14 +100,19 @@ def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool): except Exception as e: raise click.ClickException(f"request device code: {e}\nCheck --auth-url or MOBILERUN_AUTH_URL: {auth_url}") from e - console.print(f"Visit: [bold]{code.verification_uri}[/]") - console.print(f"Code: [bold cyan]{code.user_code}[/]") - if code.verification_uri_complete: - if not no_qr: - print_qr(code.verification_uri_complete) - console.print(f"[dim](Opening {code.verification_uri_complete} ...)[/]") + link = code.verification_uri_complete + terminal = sys.stderr.isatty() + if link: + if terminal and not no_qr: + print_qr(link, color="NO_COLOR" not in os.environ) + hyperlink = terminal and "NO_COLOR" not in os.environ + display = f"\033]8;;{link}\033\\{link}\033]8;;\033\\" if hyperlink else link + click.echo(display, err=True, color=hyperlink) + 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(code.verification_uri_complete) + webbrowser.open(link) except Exception: pass console.print(f"[dim]Waiting for authorization (expires in {code.expires_in}s)...[/]") @@ -134,7 +141,7 @@ def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool): "xai": (GROK_OAUTH_CREDENTIAL_PATH, "xai_oauth"), } LOGIN_OPTIONS = { - "mobilerun": {"auth_url", "client_id", "no_qr"}, + "mobilerun": {"auth_url", "client_id", "no_qr", "browser", "no_browser"}, "openai": { "credential_path", "timeout", @@ -179,6 +186,7 @@ def auth_cli(verbose): @auth_cli.command( epilog=examples( 'mobilerun auth login', + 'mobilerun auth login --browser', 'mobilerun auth login openai --no-browser', ), ) @@ -200,8 +208,10 @@ def auth_cli(verbose): ) @click.option( "--no-browser", is_flag=True, - help="Do not open a provider login browser (openai, anthropic, gemini, xai).", + 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( @@ -227,6 +237,7 @@ def login( credential_path, timeout, no_browser, + browser, no_qr, callback_host, callback_port, @@ -256,7 +267,7 @@ def login( ) if service == "mobilerun": _login_mobilerun( - auth_url, client_id or DEFAULT_DEVICE_CLIENT_ID, no_qr + auth_url, client_id or DEFAULT_DEVICE_CLIENT_ID, no_qr, browser ) if resolve_cloud_credential().source == "env": click.echo( diff --git a/mobilerun/cli/qr.py b/mobilerun/cli/qr.py index 45ac9cb3..cedd9359 100644 --- a/mobilerun/cli/qr.py +++ b/mobilerun/cli/qr.py @@ -6,7 +6,7 @@ from io import StringIO -def print_qr(data: str) -> bool: +def print_qr(data: str, *, color: bool = True) -> bool: """Print a compact QR with its quiet zone to terminal stderr.""" import segno @@ -18,7 +18,7 @@ def print_qr(data: str) -> bool: 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") + sys.stderr.write(f"\x1b[97;40m{line}\x1b[0m\n" if color else line + "\n") except Exception: return False return True diff --git a/tests/test_cli_qr.py b/tests/test_cli_qr.py index 0d034e95..6a940e0f 100644 --- a/tests/test_cli_qr.py +++ b/tests/test_cli_qr.py @@ -77,6 +77,8 @@ def test_print_qr_write_failure_returns_false(monkeypatch, error): 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) @@ -100,20 +102,20 @@ def test_login_prints_complete_link_qr_and_preserves_flow(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") + login.printed.assert_called_once_with("https://example.test/verify?user_code=ABCD-EFGH", color=True) assert "\x1b[97;40m" in result.stderr - login.browser.assert_called_once_with("https://example.test/verify?user_code=ABCD-EFGH") + 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_browser_and_polling(login): +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_called_once_with("https://example.test/verify?user_code=ABCD-EFGH") + login.browser.assert_not_called() login.poll.assert_called_once_with(auth.DEFAULT_AUTH_URL, auth.DEFAULT_DEVICE_CLIENT_ID, login.code) @@ -133,7 +135,7 @@ def test_login_continues_after_qr_render_failure(monkeypatch, 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_called_once_with("https://example.test/verify?user_code=ABCD-EFGH") + 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) @@ -481,3 +483,82 @@ def test_adb_wrapper_passes_password_only_on_stdin(monkeypatch): ["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: + assert "\x1b" not in result.stderr + 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, color=not no_color) + 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) From 144eb3f5cc746fe52ac82da80016960005fb6c19 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:05:01 +0100 Subject: [PATCH 35/59] refactor(cli): use core device api --- mobilerun/cli/device_commands.py | 8 ++------ mobilerun/cli/device_lifecycle.py | 13 ++++--------- pyproject.toml | 2 +- tests/test_android_device_selection.py | 2 +- tests/test_android_portal_http_wiring.py | 3 ++- tests/test_cli_contract.py | 6 +++--- tests/test_cli_devx.py | 3 ++- tests/test_cli_exit_codes.py | 3 +++ tests/test_cli_fix4.py | 12 ++++++------ tests/test_cli_polish_fix2.py | 2 +- tests/test_cloud_cli.py | 14 ++++++++++++-- tests/test_ios_local_portal_wiring.py | 8 ++++++-- tests/test_state_fetch_retry_policy.py | 3 +++ 13 files changed, 46 insertions(+), 33 deletions(-) diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index 33590e87..d9dc06d9 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -51,13 +51,9 @@ def device_driver(params=None, *, selection=None): try: yield connected finally: - close = getattr(connected, "close", None) - if close: - close() + connected.close() finally: - close = getattr(client, "close", None) - if close: - close() + client.close() def probe_device(params, *, selection=None): diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index 3513cba7..596fab1b 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -76,21 +76,16 @@ def _list_target(api_url, scope): devices = client.list_devices(filters={"state": None}, scope=scope) rows = [] for device in devices: - # Core seeds this model from the list response, but has no public - # metadata accessor for name/state/type on Device yet. - metadata = getattr(device._conn, "_device_cache", None) rows.append({ "device_id": device.device_id, - "name": metadata.name if metadata else None, - "state": metadata.state if metadata else "connected", - "type": metadata.type if metadata else device.capabilities["backend"], + "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]: - close = getattr(resource, "close", None) - if close: - close() + resource.close() @device_cli.command() diff --git a/pyproject.toml b/pyproject.toml index 7f45af35..c613932e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "llama-index-llms-ollama>=0.7.2", "llama-index-llms-openrouter>=0.4.2", "mobilerun-sdk>=6.1.1", - "mobilerun-core[local]>=1.11.0", + "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", diff --git a/tests/test_android_device_selection.py b/tests/test_android_device_selection.py index cca64d94..286df42a 100644 --- a/tests/test_android_device_selection.py +++ b/tests/test_android_device_selection.py @@ -182,7 +182,7 @@ def test_device_command_uses_single_online_device(adb_devices, monkeypatch): serials = [] def connect(self, serial, **kwargs): serials.append(serial) - return object() + return SimpleNamespace(close=lambda: None) monkeypatch.setattr(mobilerun_core.Mobilerun, "connect", connect) with device_driver({"api_url": DEFAULT_API_URL}): pass diff --git a/tests/test_android_portal_http_wiring.py b/tests/test_android_portal_http_wiring.py index 77c354db..8442ddd6 100644 --- a/tests/test_android_portal_http_wiring.py +++ b/tests/test_android_portal_http_wiring.py @@ -1,4 +1,5 @@ import asyncio +from types import SimpleNamespace import pytest from mobilerun_core_local.driver.android import AndroidPortalHttpDriver @@ -104,7 +105,7 @@ def test_device_commands_route_url_to_portal_http(tmp_path, monkeypatch, no_adb) created = {} def connect(self, device_id, **kwargs): created.update(kwargs) - return object() + return SimpleNamespace(close=lambda: None) monkeypatch.setattr(Mobilerun, "connect", connect) monkeypatch.setenv("MOBILERUN_ANDROID_PORTAL_TOKEN", "sekret") diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index 8ab18f3d..0005663a 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -165,7 +165,7 @@ def test_cloud_target_without_device_does_not_touch_adb(monkeypatch): def test_read_commands_emit_one_document(monkeypatch, args): import mobilerun_core - core = SimpleNamespace(device_id="id", capabilities={"backend": "local-android-adb"}, _conn=object()) + 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" @@ -369,8 +369,8 @@ 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}", _conn=SimpleNamespace( - _device_cache=SimpleNamespace(name=f"device-{state}", state=state, type="android"))) + 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) diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index 4734533a..aa47a0c5 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -324,7 +324,8 @@ def factory(url, *, cloud): scope = "cloud" if cloud else "local" failure = cloud_failure if cloud else local_failure device = SimpleNamespace( - device_id=scope, _conn=object(), capabilities={"backend": scope}, close=Mock() + device_id=scope, name=None, state=None, device_type=None, + capabilities={"backend": scope}, close=Mock() ) devices.append(device) diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py index d592f251..a5c25597 100644 --- a/tests/test_cli_exit_codes.py +++ b/tests/test_cli_exit_codes.py @@ -97,6 +97,9 @@ def test_ping_driver_error(monkeypatch): def test_device_list_core_error_is_failure(monkeypatch): class FakeCore: + def close(self): + pass + def list_devices(self, **kwargs): raise RuntimeError("adb broke") diff --git a/tests/test_cli_fix4.py b/tests/test_cli_fix4.py index 106f2456..d80fd6a2 100644 --- a/tests/test_cli_fix4.py +++ b/tests/test_cli_fix4.py @@ -11,6 +11,7 @@ import sys from contextlib import contextmanager, nullcontext from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import click @@ -59,12 +60,11 @@ def test_local_adb_missing_or_server_unreachable(monkeypatch, tmp_path, availabl def test_all_keeps_cloud_when_adb_fails(monkeypatch): monkeypatch.setenv("PATH", "") - row = type("Device", (), {"device_id": "cloud-id", "_conn": type("Conn", (), { - "_device_cache": type("Metadata", (), {"name": "phone", "state": "running", "type": "android"})() - })()})() - monkeypatch.setattr(device_lifecycle, "core_client", lambda *a, **k: type("Client", (), { - "list_devices": lambda *a, **k: [row] - })()) + 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=lambda **kwargs: [row], close=Mock(), + )) result = CliRunner().invoke(cli, ["device", "list", "--all", "-j"]) assert result.exit_code == 0, result.output body = json.loads(result.stdout) diff --git a/tests/test_cli_polish_fix2.py b/tests/test_cli_polish_fix2.py index eebd4ca6..c0caedc0 100644 --- a/tests/test_cli_polish_fix2.py +++ b/tests/test_cli_polish_fix2.py @@ -303,7 +303,7 @@ 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), + 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 diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 341ab783..d535e3d8 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -1,4 +1,5 @@ from types import SimpleNamespace +from unittest.mock import Mock from click.testing import CliRunner @@ -62,11 +63,14 @@ def test_cloud_facade_receives_url_key_and_id(monkeypatch): calls = [] class FakeCore: + def close(self): + pass + def __init__(self, **kwargs): calls.append(kwargs) def connect(self, device_id, **kwargs): calls.append({"device_id": device_id, **kwargs}) - return object() + return SimpleNamespace(close=Mock()) monkeypatch.setattr(selection.ConfigLoader, "load", lambda _: MobileConfig()) monkeypatch.setattr(selection.mobilerun_core, "Mobilerun", FakeCore) @@ -83,6 +87,9 @@ def test_cloud_listing_errors_are_failures(monkeypatch): from mobilerun.cli import device_lifecycle class FakeCore: + def close(self): + pass + def list_devices(self, **kwargs): assert kwargs["scope"] in {"cloud", "all"} raise RuntimeError("cloud broke") @@ -98,9 +105,12 @@ def test_local_listing_uses_local_scope(monkeypatch): from mobilerun.cli import device_lifecycle class FakeCore: + def close(self): + pass + def list_devices(self, **kwargs): assert kwargs["scope"] == "local" - return [SimpleNamespace(device_id="serial", capabilities={"backend": "local-android-adb"}, _conn=object())] + 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"]) diff --git a/tests/test_ios_local_portal_wiring.py b/tests/test_ios_local_portal_wiring.py index 984dba0a..b549ef43 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): @@ -116,6 +117,9 @@ def test_device_commands_ui_returns_core_state(monkeypatch): from mobilerun.cli.main import cli class Device: + def close(self): + pass + def ui_json(self): return json.dumps(LOCAL_PORTAL_STATE) monkeypatch.setattr(Mobilerun, "connect", lambda *a, **kw: Device()) @@ -132,7 +136,7 @@ def test_device_commands_route_ios_through_facade(tmp_path, monkeypatch): created = {} def connect(self, device_id, **kwargs): created.update(kwargs) - return object() + return SimpleNamespace(close=lambda: None) monkeypatch.setattr(Mobilerun, "connect", connect) monkeypatch.setenv("MOBILERUN_IOS_PORTAL_TOKEN", "sekret") cfg = tmp_path / "config.yaml" @@ -155,7 +159,7 @@ async def discover(): return "http://127.0.0.1:8081" def connect(self, device_id, **kwargs): created.update(kwargs) - return object() + 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" diff --git a/tests/test_state_fetch_retry_policy.py b/tests/test_state_fetch_retry_policy.py index ecf9be8b..4d32fade 100644 --- a/tests/test_state_fetch_retry_policy.py +++ b/tests/test_state_fetch_retry_policy.py @@ -221,6 +221,9 @@ def test_device_ui_shows_info_progress_on_stderr(monkeypatch): 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": {}}' From 063ee14ea10c6e22d2117f905906c9e9331ae130 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:05:12 +0100 Subject: [PATCH 36/59] feat(cli): read MOBILERUN_API_KEY first --- README.md | 5 +++-- mobilerun/cli/auth.py | 13 ++++++++----- mobilerun/cli/env.py | 4 ++-- mobilerun/cli/options.py | 6 ++++-- mobilerun/cli/selection.py | 16 +++++++++++++--- tests/test_cli_devx.py | 17 ++++++++++++----- tests/test_cli_fix4.py | 2 +- tests/test_cloud_cli.py | 31 +++++++++++++++++++++++++------ 8 files changed, 68 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 4d81b620..c9c61176 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,8 @@ screenshot, ui, apps, start, and ping. ### Environment For CLI options, flags override environment values, then config values and defaults. -`MOBILERUN_CLOUD_API_KEY` overrides a saved sign-in; unset it to use the sign-in. +`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 @@ -252,7 +253,7 @@ that issued the saved session, even when `MOBILERUN_AUTH_URL` has changed. | --- | --- | --- | | `MOBILERUN_TARGET` | Default target | `local` | | `MOBILERUN_DEVICE` | Default device serial, Portal URL, or cloud id | Configured serial, then local selection | -| `MOBILERUN_CLOUD_API_KEY` | Cloud authentication | Saved Mobilerun sign-in credential | +| `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 | diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py index 79f81a15..a97854b9 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -269,9 +269,10 @@ def login( _login_mobilerun( auth_url, client_id or DEFAULT_DEVICE_CLIENT_ID, no_qr, browser ) - if resolve_cloud_credential().source == "env": + credential = resolve_cloud_credential() + if credential.source == "env": click.echo( - "Warning: MOBILERUN_CLOUD_API_KEY is set and takes precedence over this sign-in; " + f"Warning: {credential.env_var} is set and takes precedence over this sign-in; " "unset it to use the sign-in.", err=True, ) return @@ -322,8 +323,10 @@ def logout(service, verbose): 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" and resolve_cloud_credential().source == "env": - console.print("MOBILERUN_CLOUD_API_KEY stays active; unset it to stop using the env key.") + 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() @@ -381,7 +384,7 @@ def status(verbose, json_output): if row["service"] == "mobilerun": cloud = ( "; cloud commands have no credential; run `mobilerun auth login` " - "or set MOBILERUN_CLOUD_API_KEY" + "or set MOBILERUN_API_KEY" if row["cloud_credential"] == "not set" else f"; cloud commands use {row['cloud_credential']}" ) diff --git a/mobilerun/cli/env.py b/mobilerun/cli/env.py index 31e3f008..979846e8 100644 --- a/mobilerun/cli/env.py +++ b/mobilerun/cli/env.py @@ -43,7 +43,7 @@ def resolved_environment(config_path=None): values = { "MOBILERUN_TARGET": ("local", "default", False), "MOBILERUN_DEVICE": (config.device.serial, config_source if config.device.serial else "not set", False), - "MOBILERUN_CLOUD_API_KEY": ( + "MOBILERUN_API_KEY": ( cloud_credential.value, {"env": "env", "login": "credential file", None: "not set"}[cloud_credential.source], True, @@ -76,7 +76,7 @@ def resolved_environment(config_path=None): values[name] = (None, "default", True) rows = [] for name, (value, source, secret) in values.items(): - if (name != "MOBILERUN_CLOUD_API_KEY" and os.environ.get(name) + if (name != "MOBILERUN_API_KEY" and os.environ.get(name) and not (name == "MOBILERUN_CONFIG" and path_source == "flag")): value, source = os.environ[name], "env" if (name == "MOBILERUN_DEBUG" and ctx and diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index 8cca072a..011dfc0c 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -141,7 +141,9 @@ def convert_api_error(exc): if exc is None: return None request = exc.request - sensitive = [os.environ.get("MOBILERUN_CLOUD_API_KEY", "")] + 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, ())) @@ -177,7 +179,7 @@ def clean(value): credential = resolve_cloud_credential() document["credential"] = credential.source or "login" if credential.source == "env": - hint = "The key in MOBILERUN_CLOUD_API_KEY was rejected. " + 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: diff --git a/mobilerun/cli/selection.py b/mobilerun/cli/selection.py index d0d80f1d..bec67b58 100644 --- a/mobilerun/cli/selection.py +++ b/mobilerun/cli/selection.py @@ -15,19 +15,29 @@ 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 = os.environ.get("MOBILERUN_CLOUD_API_KEY") + env_key = bearer_token_from_env() if env_key: - return CloudCredential(env_key, "env", bool(saved)) + 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)) @@ -111,7 +121,7 @@ def require_cloud_api_key(): key = resolve_cloud_api_key() if not key: raise click.ClickException( - "No cloud API key found. Set MOBILERUN_CLOUD_API_KEY or run `mobilerun auth login`." + "No cloud API key found. Set MOBILERUN_API_KEY or run `mobilerun auth login`." ) return key diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index aa47a0c5..a41f48c6 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -938,6 +938,11 @@ def test_completion_never_suggests_command_corrections(words, cword): 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=[ ( @@ -977,11 +982,13 @@ def test_completion_never_suggests_command_corrections(words, cword): ), ] ) -def cloud_credential_case(request, monkeypatch): +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("MOBILERUN_CLOUD_API_KEY", env) + monkeypatch.setenv(cloud_env_var, env) credentials = {"access_token": saved} if saved else None monkeypatch.setattr(deviceauth, "load_credentials", lambda: credentials) return SimpleNamespace( @@ -1134,10 +1141,10 @@ 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_CLOUD_API_KEY" + row for row in json.loads(result.stdout) if row["variable"] == "MOBILERUN_API_KEY" ) assert row == { - "variable": "MOBILERUN_CLOUD_API_KEY", + "variable": "MOBILERUN_API_KEY", "value": "set", "source": cloud_credential_case.env_source, } @@ -1148,6 +1155,6 @@ def test_env_json_uses_cloud_credential_source(cloud_credential_case): 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_CLOUD_API_KEY=set ({cloud_credential_case.env_source})\n" in result.stdout + assert f"MOBILERUN_API_KEY=set ({cloud_credential_case.env_source})\n" in result.stdout assert "planted-env-key" not in result.output assert "planted-saved-token" not in result.output diff --git a/tests/test_cli_fix4.py b/tests/test_cli_fix4.py index d80fd6a2..73cdc94e 100644 --- a/tests/test_cli_fix4.py +++ b/tests/test_cli_fix4.py @@ -163,7 +163,7 @@ def blocked_import(name, *args, **kwargs): 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_CLOUD_API_KEY" in result.stdout + 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) diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index d535e3d8..26e9000b 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -1,13 +1,14 @@ from types import SimpleNamespace from unittest.mock import Mock +import pytest from click.testing import CliRunner from mobilerun.cli import device_commands, selection from mobilerun.cli.main import cli -def test_resolve_cloud_api_key_prefers_cloud_env(monkeypatch, tmp_path): +def test_resolve_cloud_api_key_prefers_primary_env(monkeypatch, tmp_path): from mobilerun.config_manager import credential_paths monkeypatch.setattr(credential_paths, "OAUTH_CREDENTIAL_DIR", tmp_path) @@ -15,19 +16,19 @@ def test_resolve_cloud_api_key_prefers_cloud_env(monkeypatch, tmp_path): '{"access_token": "saved-token", "auth_url": "https://example.test"}' ) monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", "env-token") - monkeypatch.setenv("MOBILERUN_API_KEY", "legacy-token") + monkeypatch.setenv("MOBILERUN_API_KEY", "primary-token") - assert selection.resolve_cloud_api_key() == "env-token" + assert selection.resolve_cloud_api_key() == "primary-token" -def test_resolve_cloud_api_key_ignores_legacy_env(monkeypatch, tmp_path): +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", "legacy-token") + monkeypatch.setenv("MOBILERUN_API_KEY", "primary-token") - assert selection.resolve_cloud_api_key() is None + assert selection.resolve_cloud_api_key() == "primary-token" def test_resolve_cloud_api_key_reads_saved_credential(monkeypatch, tmp_path): @@ -116,3 +117,21 @@ def list_devices(self, **kwargs): 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 From 2fdb405feda08d951f8355a289b0873df4240727 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:09:31 +0100 Subject: [PATCH 37/59] fix(cli): allow adb startup --- README.md | 2 +- mobilerun/cli/device_commands.py | 26 +++++----- mobilerun/cli/device_lifecycle.py | 16 +++--- mobilerun/cli/device_pair.py | 6 +-- mobilerun/cli/doctor.py | 7 +-- mobilerun/cli/options.py | 18 ------- mobilerun/cli/selection.py | 65 ++++++++++++++++--------- tests/test_cli_contract.py | 1 - tests/test_cli_devx.py | 1 - tests/test_cli_exit_codes.py | 1 - tests/test_cli_fix4.py | 81 +++++++++++++++++-------------- tests/test_cli_polish_fix2.py | 1 - tests/test_cli_qr.py | 6 +-- 13 files changed, 114 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index c9c61176..927df469 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ was used, or `"credential":"login"` when the saved sign-in was used. 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`."}]} +{"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":"No adb exe could be found. Install adb on your system"}]} ``` `device list --all` lists local devices first, then cloud devices. If either diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index d9dc06d9..93dbe61f 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -11,7 +11,6 @@ import click -from mobilerun.cli import options from mobilerun.cli.options import ( Group, _ui_cell, @@ -22,7 +21,7 @@ json_option, verbose_option, ) -from mobilerun.cli.selection import core_client, portal_token, select_device +from mobilerun.cli.selection import core_client, local_adb_errors, portal_token, select_device UI_FLAGS = ("clickable", "checkable", "checked", "editable", "focused", "scrollable", "selected") @@ -38,22 +37,21 @@ def device_driver(params=None, *, selection=None): device=params.get("device"), config_path=params.get("config_path"), platform=params.get("platform"), target=params.get("target", "local"), )) - if selection.backend == "local-android-adb": - options.require_adb() token = portal_token(selection) client = core_client(params["api_url"], cloud=selection.backend == "cloud") - try: - 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, - ) + with local_adb_errors(selection.backend == "local-android-adb"): try: - yield connected + 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: - connected.close() - finally: - client.close() + client.close() def probe_device(params, *, selection=None): diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index 596fab1b..02a8fc2c 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -5,7 +5,6 @@ import click from async_adbutils import adb -from mobilerun.cli import options from mobilerun.cli.device_commands import device_cli, probe_device from mobilerun.cli.device_pair import adb_connect_succeeded from mobilerun.cli.options import ( @@ -25,7 +24,7 @@ target_option, verbose_option, ) -from mobilerun.cli.selection import core_client, select_device +from mobilerun.cli.selection import core_client, local_adb_errors, select_device @device_cli.command( @@ -68,12 +67,11 @@ def list_devices(target, api_url, verbose, json_output, all_devices): def _list_target(api_url, scope): - if scope == "local": - options.require_adb() client = core_client(api_url, cloud=scope == "cloud") devices = [] try: - devices = client.list_devices(filters={"state": None}, scope=scope) + with local_adb_errors(scope == "local"): + devices = client.list_devices(filters={"state": None}, scope=scope) rows = [] for device in devices: rows.append({ @@ -94,8 +92,8 @@ def _list_target(api_url, scope): @coro async def connect(serial, verbose): """Connect to an Android device over TCP/IP.""" - options.require_adb() - result = await adb.connect(serial) + 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) @@ -107,8 +105,8 @@ async def connect(serial, verbose): @coro async def disconnect(serial, verbose): """Disconnect an Android TCP/IP device.""" - options.require_adb() - success = await adb.disconnect(serial, raise_error=True) + 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, diff --git a/mobilerun/cli/device_pair.py b/mobilerun/cli/device_pair.py index b5089a97..6cc90fd4 100644 --- a/mobilerun/cli/device_pair.py +++ b/mobilerun/cli/device_pair.py @@ -11,10 +11,10 @@ import click -from mobilerun.cli import options 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): @@ -165,9 +165,9 @@ 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.") - options.require_adb() deadline = time.monotonic() + timeout - check = _adb("mdns", "check", deadline=deadline) + 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.") diff --git a/mobilerun/cli/doctor.py b/mobilerun/cli/doctor.py index 895e9381..421a1b54 100644 --- a/mobilerun/cli/doctor.py +++ b/mobilerun/cli/doctor.py @@ -24,7 +24,6 @@ from rich.console import Console from mobilerun import __version__ -from mobilerun.cli import options console = Console(stderr=True) @@ -165,23 +164,19 @@ async def check_config(debug: bool, config_path: str | None = None) -> tuple[Che async def check_adb(debug: bool) -> CheckResult: """Check ADB is available and list devices.""" - adb_path = "" try: - adb_path = options.require_adb() 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, - e.message if isinstance(e, options.click.ClickException) else f"adb found but server error: {e}", - detail=adb_path, + str(e), ) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index 011dfc0c..02b89495 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -7,8 +7,6 @@ import json import logging import os -import shutil -import socket import sys import traceback import unicodedata @@ -36,22 +34,6 @@ ) -def require_adb(): - """Check local tooling without invoking the library's binary fallback.""" - path = shutil.which("adb") - try: - if not path: - raise OSError("missing adb") - with socket.create_connection(( - os.environ.get("ANDROID_ADB_SERVER_HOST", "127.0.0.1"), - int(os.environ.get("ANDROID_ADB_SERVER_PORT", "5037")), - ), timeout=1): - pass - except (OSError, ValueError): - raise click.ClickException(ADB_HINT) from None - return path - - def config_debug(path): """Read logging policy without creating or migrating configuration.""" import yaml diff --git a/mobilerun/cli/selection.py b/mobilerun/cli/selection.py index bec67b58..5975c297 100644 --- a/mobilerun/cli/selection.py +++ b/mobilerun/cli/selection.py @@ -2,12 +2,36 @@ from __future__ import annotations +import os +from contextlib import contextmanager from dataclasses import dataclass, field import click -from mobilerun.cli import options -from mobilerun.cli.options import DEFAULT_API_URL +from mobilerun.cli.options import ADB_HINT, DEFAULT_API_URL + + +@contextmanager +def local_adb_errors(enabled=True): + """Translate local ADB startup failures after the library tries to start it.""" + from async_adbutils import AdbError + + try: + yield + except (AdbError, ConnectionRefusedError, FileNotFoundError) 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) or missing_binary 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 @dataclass(frozen=True) @@ -92,14 +116,13 @@ async def select_device( "Cloud device required: pass -d ; find ids with `mobilerun device list -t cloud`." ) if not discover and selected_platform != "ios": - options.require_adb() return Selection(config, None, selected_platform, "local-android-adb") if selected_platform == "ios": selected = await discover_ios_device() else: - options.require_adb() try: - selected = await resolve_android_serial() + 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 @@ -112,8 +135,6 @@ async def select_device( url=url, backend="local-android-adb" if not url and not is_uuid(selected) else None, ) - if backend == "local-android-adb": - options.require_adb() return Selection(config, selected, selected_platform, backend) @@ -148,21 +169,21 @@ async def _native_driver(selection, config, api_url): from mobilerun.agent.utils.android_device import create_android_portal_http_driver from mobilerun.agent.utils.portal_setup import portal_version_kwargs - 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: - options.require_adb() - 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 + 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): diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index 0005663a..5fe8fab8 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -29,7 +29,6 @@ def commands(group=cli, path=()): @pytest.fixture(autouse=True) def isolated_config(monkeypatch, tmp_path): - monkeypatch.setattr("mobilerun.cli.options.require_adb", lambda: "/fake/adb") 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") diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index a41f48c6..c138ed84 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -43,7 +43,6 @@ def commands(command=cli, path=()): @pytest.fixture(autouse=True) def isolated_config(monkeypatch, tmp_path): - monkeypatch.setattr("mobilerun.cli.options.require_adb", lambda: "/fake/adb") 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"): diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py index a5c25597..4a54bb21 100644 --- a/tests/test_cli_exit_codes.py +++ b/tests/test_cli_exit_codes.py @@ -12,7 +12,6 @@ @pytest.fixture(autouse=True) def config(monkeypatch): - monkeypatch.setattr("mobilerun.cli.options.require_adb", lambda: "/fake/adb") monkeypatch.setattr(selection.ConfigLoader, "load", lambda _: MobileConfig()) diff --git a/tests/test_cli_fix4.py b/tests/test_cli_fix4.py index 73cdc94e..50f86bea 100644 --- a/tests/test_cli_fix4.py +++ b/tests/test_cli_fix4.py @@ -6,10 +6,9 @@ import os import select import signal -import socket import subprocess import sys -from contextlib import contextmanager, nullcontext +from contextlib import contextmanager from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -33,37 +32,58 @@ def isolated(monkeypatch, tmp_path): monkeypatch.delenv(name, raising=False) -@pytest.mark.parametrize("available", [False, True]) +@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, tmp_path, available, args): - # Hold an unused port without listening: connection deterministically fails. - with socket.socket() as reserved: - reserved.bind(("127.0.0.1", 0)) - monkeypatch.setenv("ANDROID_ADB_SERVER_HOST", "127.0.0.1") - monkeypatch.setenv("ANDROID_ADB_SERVER_PORT", str(reserved.getsockname()[1])) - if available: - binary = tmp_path / "adb" - binary.write_text("#!/bin/sh\nexit 99\n") - binary.chmod(0o755) - monkeypatch.setenv("PATH", str(tmp_path)) - result = CliRunner().invoke(cli, args) +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.stdout == "" assert result.stderr == "Error: " + options.ADB_HINT + "\n" - assert "adbutils" not in result.output + + +def test_local_adb_server_can_start_on_first_use(monkeypatch, tmp_path): + import asyncio + + 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=lambda **kwargs: asyncio.run(listing()), 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): - monkeypatch.setenv("PATH", "") 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=lambda **kwargs: [row], close=Mock(), + 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 @@ -220,25 +240,14 @@ def test_readme_real_error_examples(): 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 + assert document["checks"][-1]["detail"] == "No adb exe could be found. Install adb on your system" -def test_doctor_and_pair_share_adb_check(monkeypatch): - doctor = importlib.import_module("mobilerun.cli.doctor") - from mobilerun.cli import device_pair - - monkeypatch.setattr(options.shutil, "which", lambda _: "/fake/adb") - monkeypatch.setattr(options.socket, "create_connection", Mock(return_value=nullcontext())) - check = Mock(return_value="/fake/adb") - monkeypatch.setattr(options, "require_adb", check) - monkeypatch.setattr(doctor.adb, "list", AsyncMock(return_value=[])) +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.message == "found, 0 device(s)" - check.assert_called_once() - monkeypatch.setattr(device_pair.sys.stderr, "isatty", lambda: True) - monkeypatch.setattr(device_pair, "_adb", Mock(side_effect=click.ClickException("mdns stop"))) - with pytest.raises(click.ClickException, match="mdns stop"): - device_pair.pair.callback(timeout=1, verbose=False) - assert check.call_count == 2 + assert result.status == doctor.Status.FAIL + assert result.message == "library startup failed" diff --git a/tests/test_cli_polish_fix2.py b/tests/test_cli_polish_fix2.py index c0caedc0..712ec2c4 100644 --- a/tests/test_cli_polish_fix2.py +++ b/tests/test_cli_polish_fix2.py @@ -20,7 +20,6 @@ @pytest.fixture(autouse=True) def isolated_config(monkeypatch, tmp_path): - monkeypatch.setattr("mobilerun.cli.options.require_adb", lambda: "/fake/adb") monkeypatch.setattr(ConfigLoader, "get_user_config_path", lambda: tmp_path / "config.yaml") for name in ( "MOBILERUN_ANDROID_PORTAL_TOKEN", "MOBILERUN_IOS_PORTAL_TOKEN", diff --git a/tests/test_cli_qr.py b/tests/test_cli_qr.py index 6a940e0f..2d23b1c7 100644 --- a/tests/test_cli_qr.py +++ b/tests/test_cli_qr.py @@ -3,7 +3,7 @@ import io import subprocess import sys -from contextlib import contextmanager, nullcontext +from contextlib import contextmanager from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -180,8 +180,6 @@ def test_mdns_parser_skips_unsafe_addresses(address): @pytest.fixture def pairing(monkeypatch): - monkeypatch.setattr(device_pair.options.shutil, "which", lambda _: "/fake/adb") - monkeypatch.setattr(device_pair.options.socket, "create_connection", Mock(return_value=nullcontext())) printed = Mock(return_value=True) monkeypatch.setattr(device_pair, "print_qr", printed) clock = SimpleNamespace(now=0.0) @@ -447,7 +445,7 @@ def test_pair_bounds_adb_calls_by_timeout(monkeypatch, pairing): def test_pair_requires_adb(monkeypatch, pairing): - monkeypatch.setattr(device_pair.options.shutil, "which", lambda _: None) + 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 From 1a0cb4af7e5ec63a726809cadb62846337f8c50f Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:11:10 +0100 Subject: [PATCH 38/59] fix(cli): remove import shims --- mobilerun/cli/auth.py | 30 --------------------- mobilerun/cli/configure.py | 18 ------------- mobilerun/cli/device_commands.py | 12 --------- mobilerun/cli/device_lifecycle.py | 12 --------- mobilerun/cli/doctor_command.py | 14 ---------- mobilerun/cli/env.py | 20 -------------- mobilerun/cli/options.py | 31 ---------------------- mobilerun/cli/providers.py | 14 ---------- mobilerun/cli/qr.py | 12 --------- mobilerun/cli/run.py | 37 +++++--------------------- mobilerun/cli/selection.py | 43 +++---------------------------- mobilerun/macro/cli.py | 17 ------------ tests/test_cli_contract.py | 3 ++- tests/test_cli_devx.py | 6 +++-- tests/test_cli_exit_codes.py | 6 +++-- tests/test_cli_qr.py | 11 ++++---- tests/test_cloud_cli.py | 9 ++++--- tests/test_core_local_runtime.py | 6 +++-- tests/test_vision_only_run_cli.py | 7 ++--- 19 files changed, 40 insertions(+), 268 deletions(-) diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py index a97854b9..eeadf56f 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -391,33 +391,3 @@ def status(verbose, json_output): if row["env_overrides_login"]: cloud += " (overrides saved sign-in)" emit(f"{row['service']}: {label}{email} {row['credential_path']}{cloud}") - - -from mobilerun.cli.options import _lazy_imports # noqa: E402 - -__getattr__ = _lazy_imports(globals(), { - '_OAUTH_CREDENTIAL_SLOTS': ('mobilerun.cli.configure_wizard', '_OAUTH_CREDENTIAL_SLOTS'), - '_oauth_credentials_present': ('mobilerun.cli.configure_wizard', '_oauth_credentials_present'), - 'login_requires_input': ('mobilerun.cli.oauth_actions', 'login_requires_input'), - 'run_anthropic_setup_token_oauth': ('mobilerun.cli.oauth_actions', 'run_anthropic_setup_token_oauth'), - 'run_gemini_oauth_login': ('mobilerun.cli.oauth_actions', 'run_gemini_oauth_login'), - 'run_grok_oauth_login': ('mobilerun.cli.oauth_actions', 'run_grok_oauth_login'), - 'run_openai_oauth_login': ('mobilerun.cli.oauth_actions', 'run_openai_oauth_login'), - 'save_anthropic_setup_token': ('mobilerun.cli.oauth_actions', 'save_anthropic_setup_token'), -}) - -from typing import TYPE_CHECKING # noqa: E402 - -if TYPE_CHECKING: - from mobilerun.cli.configure_wizard import _OAUTH_CREDENTIAL_SLOTS as _OAUTH_CREDENTIAL_SLOTS - from mobilerun.cli.configure_wizard import ( - _oauth_credentials_present as _oauth_credentials_present, - ) - from mobilerun.cli.oauth_actions import login_requires_input as login_requires_input - from mobilerun.cli.oauth_actions import ( - run_anthropic_setup_token_oauth as run_anthropic_setup_token_oauth, - ) - from mobilerun.cli.oauth_actions import run_gemini_oauth_login as run_gemini_oauth_login - from mobilerun.cli.oauth_actions import run_grok_oauth_login as run_grok_oauth_login - from mobilerun.cli.oauth_actions import run_openai_oauth_login as run_openai_oauth_login - from mobilerun.cli.oauth_actions import save_anthropic_setup_token as save_anthropic_setup_token diff --git a/mobilerun/cli/configure.py b/mobilerun/cli/configure.py index 9e90a0bb..d57fe06e 100644 --- a/mobilerun/cli/configure.py +++ b/mobilerun/cli/configure.py @@ -111,21 +111,3 @@ def _validate_non_interactive(provider, model, api_key, base_url, auth_mode): f"Saved OAuth credentials required; run `mobilerun auth login {spec.family}` " "in a terminal first, or choose -p PROVIDER with API-key authentication." ) - - -from mobilerun.cli.options import _lazy_imports # noqa: E402 - -__getattr__ = _lazy_imports(globals(), { - 'ConfigureWizardCallbacks': ('mobilerun.cli.configure_wizard', 'ConfigureWizardCallbacks'), - '_oauth_credentials_present': ('mobilerun.cli.configure_wizard', '_oauth_credentials_present'), - 'run_configure_wizard': ('mobilerun.cli.configure_wizard', 'run_configure_wizard'), -}) - -from typing import TYPE_CHECKING # noqa: E402 - -if TYPE_CHECKING: - from mobilerun.cli.configure_wizard import ConfigureWizardCallbacks as ConfigureWizardCallbacks - from mobilerun.cli.configure_wizard import ( - _oauth_credentials_present as _oauth_credentials_present, - ) - from mobilerun.cli.configure_wizard import run_configure_wizard as run_configure_wizard diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index 93dbe61f..65314c93 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -249,15 +249,3 @@ def start(package, **params): with device_driver(params) as device: device.start_app(package) click.echo(f"Started {package}", err=True) - - -from mobilerun.cli.options import _lazy_imports # noqa: E402 - -__getattr__ = _lazy_imports(globals(), { - 'is_portal_http_url': ('mobilerun.agent.utils.android_device', 'is_portal_http_url'), -}) - -from typing import TYPE_CHECKING # noqa: E402 - -if TYPE_CHECKING: - from mobilerun.agent.utils.android_device import is_portal_http_url as is_portal_http_url diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index 02a8fc2c..a975ad43 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -145,15 +145,3 @@ 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) - - -from mobilerun.cli.options import _lazy_imports # noqa: E402 - -__getattr__ = _lazy_imports(globals(), { - '_setup_portal': ('mobilerun.cli.setup', '_setup_portal'), -}) - -from typing import TYPE_CHECKING # noqa: E402 - -if TYPE_CHECKING: - from mobilerun.cli.setup import _setup_portal as _setup_portal diff --git a/mobilerun/cli/doctor_command.py b/mobilerun/cli/doctor_command.py index ac0168eb..63843b81 100644 --- a/mobilerun/cli/doctor_command.py +++ b/mobilerun/cli/doctor_command.py @@ -39,17 +39,3 @@ async def doctor(device, config_path, platform, target, api_url, verbose, json_o emit(f"{check['name']}: {check['status']}: {check['detail']}") if failed: click.get_current_context().exit(1) - - -from mobilerun.cli.options import _lazy_imports # noqa: E402 - -__getattr__ = _lazy_imports(globals(), { - 'Status': ('mobilerun.cli.doctor', 'Status'), - 'run_doctor': ('mobilerun.cli.doctor', 'run_doctor'), -}) - -from typing import TYPE_CHECKING # noqa: E402 - -if TYPE_CHECKING: - from mobilerun.cli.doctor import Status as Status - from mobilerun.cli.doctor import run_doctor as run_doctor diff --git a/mobilerun/cli/env.py b/mobilerun/cli/env.py index 979846e8..c595921f 100644 --- a/mobilerun/cli/env.py +++ b/mobilerun/cli/env.py @@ -114,23 +114,3 @@ def env(config_path, verbose, json_output): show_source = not (value == "not set" and row["source"] == "not set") source = f" ({row['source']})" if show_source else "" emit(f"{row['variable']}={value}{source}") - - -from mobilerun.cli.options import _lazy_imports # noqa: E402 - -__getattr__ = _lazy_imports(globals(), { - 'ConfigLoader': ('mobilerun.config_manager', 'ConfigLoader'), - 'MobileConfig': ('mobilerun.config_manager', 'MobileConfig'), - 'PORTAL_TOKEN_ENV_VARS': ('mobilerun.config_manager.config_manager', 'PORTAL_TOKEN_ENV_VARS'), - 'is_telemetry_enabled': ('mobilerun.telemetry.tracker', 'is_telemetry_enabled'), -}) - -from typing import TYPE_CHECKING # noqa: E402 - -if TYPE_CHECKING: - from mobilerun.config_manager import ConfigLoader as ConfigLoader - from mobilerun.config_manager import MobileConfig as MobileConfig - from mobilerun.config_manager.config_manager import ( - PORTAL_TOKEN_ENV_VARS as PORTAL_TOKEN_ENV_VARS, - ) - from mobilerun.telemetry.tracker import is_telemetry_enabled as is_telemetry_enabled diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index 02b89495..a9ce6261 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -501,34 +501,3 @@ def 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 "") - - - -def _lazy_imports(namespace, exports): - """Preserve module export paths while deferring optional dependencies.""" - from importlib import import_module - - def get_attribute(name): - if name not in exports: - raise AttributeError(f"module {namespace['__name__']!r} has no attribute {name!r}") - module, attribute = exports[name] - value = import_module(module) - if attribute is not None: - value = getattr(value, attribute) - namespace[name] = value - return value - - return get_attribute - -__getattr__ = _lazy_imports(globals(), { - 'APIConnectionError': ('mobilerun_sdk', 'APIConnectionError'), - 'APIStatusError': ('mobilerun_sdk', 'APIStatusError'), - 'APITimeoutError': ('mobilerun_sdk', 'APITimeoutError'), -}) - -from typing import TYPE_CHECKING # noqa: E402 - -if TYPE_CHECKING: - from mobilerun_sdk import APIConnectionError as APIConnectionError - from mobilerun_sdk import APIStatusError as APIStatusError - from mobilerun_sdk import APITimeoutError as APITimeoutError diff --git a/mobilerun/cli/providers.py b/mobilerun/cli/providers.py index 540aba21..d311f0c7 100644 --- a/mobilerun/cli/providers.py +++ b/mobilerun/cli/providers.py @@ -59,17 +59,3 @@ def llm_url_kwargs(runtime, url): 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} - - -from mobilerun.cli.options import _lazy_imports # noqa: E402 - -__getattr__ = _lazy_imports(globals(), { - 'resolve_provider_variant': ('mobilerun.agent.providers.registry', 'resolve_provider_variant'), -}) - -from typing import TYPE_CHECKING # noqa: E402 - -if TYPE_CHECKING: - from mobilerun.agent.providers.registry import ( - resolve_provider_variant as resolve_provider_variant, - ) diff --git a/mobilerun/cli/qr.py b/mobilerun/cli/qr.py index cedd9359..4166b39c 100644 --- a/mobilerun/cli/qr.py +++ b/mobilerun/cli/qr.py @@ -22,15 +22,3 @@ def print_qr(data: str, *, color: bool = True) -> bool: except Exception: return False return True - - -from mobilerun.cli.options import _lazy_imports # noqa: E402 - -__getattr__ = _lazy_imports(globals(), { - 'segno': ('segno', None), -}) - -from typing import TYPE_CHECKING # noqa: E402 - -if TYPE_CHECKING: - import segno as segno diff --git a/mobilerun/cli/run.py b/mobilerun/cli/run.py index 4b5debe5..47212297 100644 --- a/mobilerun/cli/run.py +++ b/mobilerun/cli/run.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING import click from async_adbutils import adb @@ -31,6 +32,11 @@ console = Console(stderr=True) +if TYPE_CHECKING: + from mobilerun import ResultEvent + from mobilerun.config_manager import MobileConfig + + def _force_screenshot_only_vision(config: MobileConfig) -> None: config.agent.vision_only = True config.agent.manager.vision = True @@ -398,34 +404,3 @@ async def run( emit({"success": success, "reason": reason, "steps": steps}, json_output=True) if not success: raise click.ClickException(reason or "Task failed") - - -from mobilerun.cli.options import _lazy_imports # noqa: E402 - -__getattr__ = _lazy_imports(globals(), { - 'VISUAL_REMOTE_CONNECTION': ('mobilerun_core_local.driver.visual_remote', 'VISUAL_REMOTE_CONNECTION'), - 'MobileAgent': ('mobilerun', 'MobileAgent'), - 'ResultEvent': ('mobilerun', 'ResultEvent'), - 'is_portal_http_url': ('mobilerun.agent.utils.android_device', 'is_portal_http_url'), - 'load_llm': ('mobilerun.agent.utils.llm_picker', 'load_llm'), - 'EventHandler': ('mobilerun.cli.event_handler', 'EventHandler'), - 'ConfigLoader': ('mobilerun.config_manager', 'ConfigLoader'), - 'MobileConfig': ('mobilerun.config_manager', 'MobileConfig'), - 'print_telemetry_message': ('mobilerun.telemetry', 'print_telemetry_message'), -}) - -from typing import TYPE_CHECKING # noqa: E402 - -if TYPE_CHECKING: - from mobilerun_core_local.driver.visual_remote import ( - VISUAL_REMOTE_CONNECTION as VISUAL_REMOTE_CONNECTION, - ) - - from mobilerun import MobileAgent as MobileAgent - from mobilerun import ResultEvent as ResultEvent - from mobilerun.agent.utils.android_device import is_portal_http_url as is_portal_http_url - from mobilerun.agent.utils.llm_picker import load_llm as load_llm - from mobilerun.cli.event_handler import EventHandler as EventHandler - from mobilerun.config_manager import ConfigLoader as ConfigLoader - from mobilerun.config_manager import MobileConfig as MobileConfig - from mobilerun.telemetry import print_telemetry_message as print_telemetry_message diff --git a/mobilerun/cli/selection.py b/mobilerun/cli/selection.py index 5975c297..a3437925 100644 --- a/mobilerun/cli/selection.py +++ b/mobilerun/cli/selection.py @@ -5,11 +5,15 @@ 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): @@ -196,42 +200,3 @@ async def teardown_native_driver(driver): await driver.device.shell(f"ime disable {portal_ime_id(PORTAL_PACKAGE_NAME)}") except Exception: pass - - -from mobilerun.cli.options import _lazy_imports # noqa: E402 - -__getattr__ = _lazy_imports(globals(), { - 'mobilerun_core': ('mobilerun_core', None), - 'is_uuid': ('mobilerun_core.detect', 'is_uuid'), - 'resolve_backend': ('mobilerun_core.detect', 'resolve_backend'), - 'discover_ios_device': ('mobilerun_core_local.driver.ios', 'discover_ios_device'), - 'validate_ios_portal_url': ('mobilerun_core_local.driver.ios', 'validate_ios_portal_url'), - 'AndroidDeviceSelectionError': ('mobilerun.agent.utils.android_device', 'AndroidDeviceSelectionError'), - 'is_portal_http_url': ('mobilerun.agent.utils.android_device', 'is_portal_http_url'), - 'resolve_android_serial': ('mobilerun.agent.utils.android_device', 'resolve_android_serial'), - 'ConfigLoader': ('mobilerun.config_manager', 'ConfigLoader'), - 'MobileConfig': ('mobilerun.config_manager', 'MobileConfig'), - 'PORTAL_TOKEN_ENV_VARS': ('mobilerun.config_manager.config_manager', 'PORTAL_TOKEN_ENV_VARS'), -}) - -from typing import TYPE_CHECKING # noqa: E402 - -if TYPE_CHECKING: - import mobilerun_core as mobilerun_core - from mobilerun_core.detect import is_uuid as is_uuid - from mobilerun_core.detect import resolve_backend as resolve_backend - from mobilerun_core_local.driver.ios import discover_ios_device as discover_ios_device - from mobilerun_core_local.driver.ios import validate_ios_portal_url as validate_ios_portal_url - - from mobilerun.agent.utils.android_device import ( - AndroidDeviceSelectionError as AndroidDeviceSelectionError, - ) - from mobilerun.agent.utils.android_device import is_portal_http_url as is_portal_http_url - from mobilerun.agent.utils.android_device import ( - resolve_android_serial as resolve_android_serial, - ) - from mobilerun.config_manager import ConfigLoader as ConfigLoader - from mobilerun.config_manager import MobileConfig as MobileConfig - from mobilerun.config_manager.config_manager import ( - PORTAL_TOKEN_ENV_VARS as PORTAL_TOKEN_ENV_VARS, - ) diff --git a/mobilerun/macro/cli.py b/mobilerun/macro/cli.py index 2fda26fb..0f7345c5 100644 --- a/mobilerun/macro/cli.py +++ b/mobilerun/macro/cli.py @@ -354,20 +354,3 @@ def console_render(table): if __name__ == "__main__": macro_cli() - - -from mobilerun.cli.options import _lazy_imports # noqa: E402 - -__getattr__ = _lazy_imports(globals(), { - 'Table': ('rich.table', 'Table'), - 'Trajectory': ('mobilerun.agent.utils.trajectory', 'Trajectory'), - 'MacroPlayer': ('mobilerun.macro.replay', 'MacroPlayer'), -}) - -from typing import TYPE_CHECKING # noqa: E402 - -if TYPE_CHECKING: - from rich.table import Table as Table - - from mobilerun.agent.utils.trajectory import Trajectory as Trajectory - from mobilerun.macro.replay import MacroPlayer as MacroPlayer diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index 5fe8fab8..24052320 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -12,6 +12,7 @@ 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 @@ -396,7 +397,7 @@ def test_config_provider_url_never_crashes_with_stop_iteration(monkeypatch, runt config = MobileConfig() for profile in config.llm_profiles.values(): profile.provider = runtime - monkeypatch.setattr(run_module.ConfigLoader, "load", lambda _: config) + 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 diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index c138ed84..088fbd7d 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -10,11 +10,13 @@ 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, @@ -632,7 +634,7 @@ def test_no_input_configure_never_starts_missing_oauth_login(monkeypatch): def test_click_prompt_eof_is_one_failure_sentence(monkeypatch, allow_input): monkeypatch.setattr( - configure, "run_configure_wizard", lambda *args, **kwargs: click.prompt("Value") + 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 @@ -1019,7 +1021,7 @@ def invoke(status, flags): listing = Mock(side_effect=error) client = SimpleNamespace(list_devices=listing, close=Mock()) factory = Mock(return_value=client) - monkeypatch.setattr(selection.mobilerun_core, "Mobilerun", factory) + 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( diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py index 4a54bb21..fc9d334e 100644 --- a/tests/test_cli_exit_codes.py +++ b/tests/test_cli_exit_codes.py @@ -1,10 +1,12 @@ + import importlib from unittest.mock import AsyncMock import pytest from click.testing import CliRunner -from mobilerun.cli import device_lifecycle, main, selection +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") @@ -12,7 +14,7 @@ @pytest.fixture(autouse=True) def config(monkeypatch): - monkeypatch.setattr(selection.ConfigLoader, "load", lambda _: MobileConfig()) + monkeypatch.setattr(mobilerun.config_manager.ConfigLoader, "load", lambda _: MobileConfig()) def test_connect_fresh_success(monkeypatch): diff --git a/tests/test_cli_qr.py b/tests/test_cli_qr.py index 2d23b1c7..16d6559e 100644 --- a/tests/test_cli_qr.py +++ b/tests/test_cli_qr.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, Mock import pytest +import segno from click.testing import CliRunner from mobilerun.cli import auth, device_lifecycle, device_pair, deviceauth, qr @@ -42,9 +43,9 @@ def test_print_qr_uses_stderr_and_preserves_quiet_zone(monkeypatch): output.isatty = lambda: True monkeypatch.setattr(qr.sys, "stderr", output) payload = "https://example.test/verify?user_code=ABCD-EFGH" - code = qr.segno.make_qr(payload) + code = segno.make_qr(payload) make = Mock(return_value=code) - monkeypatch.setattr(qr.segno, "make_qr", make) + monkeypatch.setattr(segno, "make_qr", make) assert qr.print_qr(payload) is True make.assert_called_once_with(payload) lines = output.getvalue().splitlines() @@ -59,7 +60,7 @@ 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(qr.segno, "make_qr", Mock(side_effect=error)) + monkeypatch.setattr(segno, "make_qr", Mock(side_effect=error)) assert qr.print_qr("https://example.test/verify") is False assert output.getvalue() == "" @@ -130,7 +131,7 @@ def test_login_without_complete_link_skips_qr_and_browser(login): def test_login_continues_after_qr_render_failure(monkeypatch, login): - monkeypatch.setattr(qr.segno, "make_qr", Mock(side_effect=ValueError("cannot encode"))) + 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 == "" @@ -385,7 +386,7 @@ def test_device_connect_reply(monkeypatch, reply, exit_code): def test_pair_fails_if_qr_cannot_be_rendered(monkeypatch, pairing): monkeypatch.setattr(device_pair, "print_qr", qr.print_qr) - monkeypatch.setattr(qr.segno, "make_qr", Mock(side_effect=ValueError("cannot encode"))) + 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 diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index 26e9000b..1de6fa45 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -1,9 +1,12 @@ + from types import SimpleNamespace from unittest.mock import Mock +import mobilerun_core import pytest from click.testing import CliRunner +import mobilerun.config_manager from mobilerun.cli import device_commands, selection from mobilerun.cli.main import cli @@ -51,7 +54,7 @@ def test_cloud_uuid_requires_credential_without_falling_back_to_adb(monkeypatch) from mobilerun.cli import selection from mobilerun.config_manager import MobileConfig - monkeypatch.setattr(selection.ConfigLoader, "load", lambda _: MobileConfig()) + monkeypatch.setattr(mobilerun.config_manager.ConfigLoader, "load", lambda _: MobileConfig()) 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"}): @@ -73,8 +76,8 @@ def connect(self, device_id, **kwargs): calls.append({"device_id": device_id, **kwargs}) return SimpleNamespace(close=Mock()) - monkeypatch.setattr(selection.ConfigLoader, "load", lambda _: MobileConfig()) - monkeypatch.setattr(selection.mobilerun_core, "Mobilerun", FakeCore) + 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"}): diff --git a/tests/test_core_local_runtime.py b/tests/test_core_local_runtime.py index c9bb30e8..515d57e0 100644 --- a/tests/test_core_local_runtime.py +++ b/tests/test_core_local_runtime.py @@ -1,6 +1,8 @@ + import asyncio from types import SimpleNamespace +import mobilerun.config_manager from mobilerun.cli import selection from mobilerun.config_manager.config_manager import DeviceConfig @@ -34,7 +36,7 @@ async def connect(self): pass monkeypatch.setattr( - selection.ConfigLoader, + mobilerun.config_manager.ConfigLoader, "load", lambda _path: SimpleNamespace( device=SimpleNamespace( @@ -78,7 +80,7 @@ async def fake_ensure_portal_ready(*args, **kwargs): setup_calls.append((args, kwargs)) monkeypatch.setattr( - selection.ConfigLoader, + mobilerun.config_manager.ConfigLoader, "load", lambda _path: SimpleNamespace( device=SimpleNamespace( diff --git a/tests/test_vision_only_run_cli.py b/tests/test_vision_only_run_cli.py index 5afde851..7e640e91 100644 --- a/tests/test_vision_only_run_cli.py +++ b/tests/test_vision_only_run_cli.py @@ -1,3 +1,4 @@ + import asyncio import unittest from types import SimpleNamespace @@ -102,7 +103,7 @@ def run(self): with ( patch( - "mobilerun.cli.run.ConfigLoader.load", + "mobilerun.cli.mobilerun.config_manager.ConfigLoader.load", return_value=MobileConfig(), ), patch("mobilerun.MobileAgent", FakeAgent), @@ -155,7 +156,7 @@ def run(self): with ( patch( - "mobilerun.cli.run.ConfigLoader.load", + "mobilerun.cli.mobilerun.config_manager.ConfigLoader.load", return_value=MobileConfig(), ), patch("mobilerun.MobileAgent", FakeAgent), @@ -210,7 +211,7 @@ def run(self): ) with ( - patch("mobilerun.cli.run.ConfigLoader.load", return_value=config), + patch("mobilerun.cli.mobilerun.config_manager.ConfigLoader.load", return_value=config), patch("mobilerun.MobileAgent", FakeAgent), patch("mobilerun.cli.run.adb.device", AsyncMock()) as adb_device, ): From a7a6dde50af4d4b6fdde825f3491d20aede6ec37 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:11:10 +0100 Subject: [PATCH 39/59] fix(cli): restore subpackage attributes --- mobilerun/__init__.py | 2 ++ mobilerun/agent/__init__.py | 2 ++ tests/test_cli_startup.py | 1 + 3 files changed, 5 insertions(+) diff --git a/mobilerun/__init__.py b/mobilerun/__init__.py index 4c27e328..8e4e3626 100644 --- a/mobilerun/__init__.py +++ b/mobilerun/__init__.py @@ -138,6 +138,8 @@ def __getattr__(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}") diff --git a/mobilerun/agent/__init__.py b/mobilerun/agent/__init__.py index 7c9ac2da..eff36c0d 100644 --- a/mobilerun/agent/__init__.py +++ b/mobilerun/agent/__init__.py @@ -17,6 +17,8 @@ def __getattr__(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}") diff --git a/tests/test_cli_startup.py b/tests/test_cli_startup.py index 90e4a6b4..700db9a1 100644 --- a/tests/test_cli_startup.py +++ b/tests/test_cli_startup.py @@ -72,6 +72,7 @@ "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 280f365ed24c943165e07a72355b3878bb2d5772 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:11:42 +0100 Subject: [PATCH 40/59] fix(cli): contain import interrupts --- mobilerun/__init__.py | 45 +++++++++++------------ mobilerun/cli/main.py | 82 ++---------------------------------------- mobilerun/cli/tree.py | 79 ++++++++++++++++++++++++++++++++++++++++ tests/test_cli_fix4.py | 2 +- 4 files changed, 103 insertions(+), 105 deletions(-) create mode 100644 mobilerun/cli/tree.py diff --git a/mobilerun/__init__.py b/mobilerun/__init__.py index 8e4e3626..f1981ac4 100644 --- a/mobilerun/__init__.py +++ b/mobilerun/__init__.py @@ -2,37 +2,32 @@ Mobilerun - A framework for controlling Android devices through LLM agents. """ -try: - import logging - from importlib import import_module - from importlib.metadata import version - from typing import TYPE_CHECKING +import logging +from importlib import import_module +from importlib.metadata import version +from typing import TYPE_CHECKING - __version__ = version("mobilerun") +from mobilerun.agent.utils.errors import HtmlErrorPageFilter +from mobilerun.log_handlers import CLILogHandler - # 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 +__version__ = version("mobilerun") - _logger = logging.getLogger("mobilerun") - _logger.addHandler(CLILogHandler()) - _logger.setLevel(logging.INFO) - _logger.propagate = False +# 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.agent.utils.errors import HtmlErrorPageFilter # noqa: E402 - - # llama-index's retry warnings include provider error bodies verbatim. - for _retry_logger in ( - "llama_index.llms.openai.utils", - "llama_index.llms.google_genai.utils", - ): - logging.getLogger(_retry_logger).addFilter(HtmlErrorPageFilter()) - -except KeyboardInterrupt: - raise SystemExit(130) from None +_logger = logging.getLogger("mobilerun") +_logger.addHandler(CLILogHandler()) +_logger.setLevel(logging.INFO) +_logger.propagate = False +# llama-index's retry warnings include provider error bodies verbatim. +for _retry_logger in ( + "llama_index.llms.openai.utils", + "llama_index.llms.google_genai.utils", +): + logging.getLogger(_retry_logger).addFilter(HtmlErrorPageFilter()) if TYPE_CHECKING: from mobilerun.agent import ResultEvent diff --git a/mobilerun/cli/main.py b/mobilerun/cli/main.py index ce1006b3..bb246ae1 100644 --- a/mobilerun/cli/main.py +++ b/mobilerun/cli/main.py @@ -1,83 +1,7 @@ -"""Mobilerun CLI entry point.""" +"""Interrupt-safe CLI import boundary.""" try: - 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) - - _EXAMPLES = { - "device screenshot": "mobilerun device screenshot -d emulator-5554", - "device tap": "mobilerun device tap 100 200", - "device swipe": "mobilerun device swipe 100 500 100 100", - "device long-press": "mobilerun device long-press 100 200", - "device type": 'mobilerun device type "Hello"', - "device press": "mobilerun device press home", - "device apps": "mobilerun device apps -j", - "device start": "mobilerun device start com.android.settings", - "device connect": "mobilerun device connect 192.0.2.1:5555", - "device disconnect": "mobilerun device disconnect 192.0.2.1:5555", - "device setup": "mobilerun device setup -d emulator-5554", - "device ping": "mobilerun device ping -d emulator-5554", - "auth": "mobilerun auth status", - "auth status": "mobilerun auth status -j", - "auth logout": "mobilerun auth logout", - "doctor": "mobilerun doctor -j", - "env": "mobilerun env -j", - "macro": "mobilerun macro list ./macros", - "macro list": "mobilerun macro list ./macros", - "completion": "mobilerun completion bash", - } - for _path, _example in _EXAMPLES.items(): - _command = cli - for _name in _path.split(): - _command = _command.commands[_name] - if not _command.epilog: - _command.epilog = examples(_example) - + 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/tree.py b/mobilerun/cli/tree.py new file mode 100644 index 00000000..ade44620 --- /dev/null +++ b/mobilerun/cli/tree.py @@ -0,0 +1,79 @@ +"""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) + +_EXAMPLES = { + "device screenshot": "mobilerun device screenshot -d emulator-5554", + "device tap": "mobilerun device tap 100 200", + "device swipe": "mobilerun device swipe 100 500 100 100", + "device long-press": "mobilerun device long-press 100 200", + "device type": 'mobilerun device type "Hello"', + "device press": "mobilerun device press home", + "device apps": "mobilerun device apps -j", + "device start": "mobilerun device start com.android.settings", + "device connect": "mobilerun device connect 192.0.2.1:5555", + "device disconnect": "mobilerun device disconnect 192.0.2.1:5555", + "device setup": "mobilerun device setup -d emulator-5554", + "device ping": "mobilerun device ping -d emulator-5554", + "auth": "mobilerun auth status", + "auth status": "mobilerun auth status -j", + "auth logout": "mobilerun auth logout", + "doctor": "mobilerun doctor -j", + "env": "mobilerun env -j", + "macro": "mobilerun macro list ./macros", + "macro list": "mobilerun macro list ./macros", + "completion": "mobilerun completion bash", +} +for _path, _example in _EXAMPLES.items(): + _command = cli + for _name in _path.split(): + _command = _command.commands[_name] + if not _command.epilog: + _command.epilog = examples(_example) diff --git a/tests/test_cli_fix4.py b/tests/test_cli_fix4.py index 50f86bea..b86dbe9f 100644 --- a/tests/test_cli_fix4.py +++ b/tests/test_cli_fix4.py @@ -149,7 +149,7 @@ def test_removed_flag_guidance(flag, replacement, path): assert "Did you mean" not in result.output -@pytest.mark.parametrize("blocked", ["rich.console", "click"]) +@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. From 96cb25bd58d7bab7ebb0f666cf9b405f9879ef52 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:12:26 +0100 Subject: [PATCH 41/59] fix(cli): distinguish forbidden credentials --- mobilerun/cli/options.py | 6 +++++- tests/test_cli_devx.py | 10 ++++++++-- tests/test_cli_polish_fix2.py | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index a9ce6261..e857ac97 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -160,7 +160,11 @@ def clean(value): credential = resolve_cloud_credential() document["credential"] = credential.source or "login" - if credential.source == "env": + if status == 403: + label = "env key" if credential.source == "env" else "saved sign-in" + 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." diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index 088fbd7d..d185ca57 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -1041,10 +1041,13 @@ 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{cloud_credential_case.hint}\n" + == f"Error: Rejected [redacted] (HTTP {status})\n{hint}\n" ) @@ -1053,6 +1056,9 @@ 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, @@ -1061,7 +1067,7 @@ def test_cloud_rejection_json_names_used_credential( } assert ( result.stderr - == f"Error: Rejected [redacted] (HTTP {status})\n{cloud_credential_case.hint}\n" + == f"Error: Rejected [redacted] (HTTP {status})\n{hint}\n" ) diff --git a/tests/test_cli_polish_fix2.py b/tests/test_cli_polish_fix2.py index 712ec2c4..6cfbd342 100644 --- a/tests/test_cli_polish_fix2.py +++ b/tests/test_cli_polish_fix2.py @@ -261,7 +261,7 @@ def sdk_error_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, "MOBILERUN_CLOUD_API_KEY"), + ("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"}]}, From 439e2edaf81a58db73bc7be9f7eca5db0f967b0c Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:12:26 +0100 Subject: [PATCH 42/59] fix(cli): handle missing credentials --- mobilerun/cli/options.py | 6 ++++-- tests/test_cli_devx.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index e857ac97..c999906e 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -159,8 +159,10 @@ def clean(value): from mobilerun.cli.selection import resolve_cloud_credential credential = resolve_cloud_credential() - document["credential"] = credential.source or "login" - if status == 403: + document["credential"] = credential.source + if credential.source is None: + hint = "Run `mobilerun auth login` or set MOBILERUN_API_KEY." + elif status == 403: label = "env key" if credential.source == "env" else "saved sign-in" hint = (f"The {label} is valid but not allowed to do this; " "check its scopes or the device owner.") diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index d185ca57..f94ccab7 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -1165,3 +1165,15 @@ def test_env_text_uses_cloud_credential_source(cloud_credential_case): assert f"MOBILERUN_API_KEY=set ({cloud_credential_case.env_source})\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 From c96a7ed0334c123ffe4f1e0f2541996d8fc2395b Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:13:02 +0100 Subject: [PATCH 43/59] fix(cli): unify credential sources --- mobilerun/cli/auth.py | 24 +++++++++++------------- mobilerun/cli/env.py | 12 +++++++----- mobilerun/cli/options.py | 4 ++-- mobilerun/cli/selection.py | 3 +++ tests/test_cli_devx.py | 6 +++--- 5 files changed, 26 insertions(+), 23 deletions(-) diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py index eeadf56f..e1f06793 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -24,7 +24,7 @@ verbose_option, ) from mobilerun.cli.qr import print_qr -from mobilerun.cli.selection import resolve_cloud_credential +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, @@ -339,14 +339,13 @@ def status(verbose, json_output): credentials = deviceauth.load_credentials() or {} token = credentials.get("access_token") credential = resolve_cloud_credential() - cloud_label = {"env": "env key", "login": "saved sign-in", None: "not set"}[credential.source] rows = [ { "service": "mobilerun", "signed_in": bool(token), "credential_path": str(deviceauth.credential_path()), "email": None, - "cloud_credential": cloud_label, + "cloud_credential": credential.source, "env_overrides_login": credential.source == "env" and credential.saved_sign_in, } ] @@ -377,17 +376,16 @@ def status(verbose, json_output): 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 = "" - if row["service"] == "mobilerun": - cloud = ( - "; cloud commands have no credential; run `mobilerun auth login` " - "or set MOBILERUN_API_KEY" - if row["cloud_credential"] == "not set" - else f"; cloud commands use {row['cloud_credential']}" - ) - if row["env_overrides_login"]: - cloud += " (overrides saved sign-in)" + cloud = cloud_note if row["service"] == "mobilerun" else "" emit(f"{row['service']}: {label}{email} {row['credential_path']}{cloud}") diff --git a/mobilerun/cli/env.py b/mobilerun/cli/env.py index c595921f..04c2fe2a 100644 --- a/mobilerun/cli/env.py +++ b/mobilerun/cli/env.py @@ -16,7 +16,7 @@ json_option, verbose_option, ) -from mobilerun.cli.selection import resolve_cloud_credential +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"} @@ -45,7 +45,7 @@ def resolved_environment(config_path=None): "MOBILERUN_DEVICE": (config.device.serial, config_source if config.device.serial else "not set", False), "MOBILERUN_API_KEY": ( cloud_credential.value, - {"env": "env", "login": "credential file", None: "not set"}[cloud_credential.source], + cloud_credential.source, True, ), "MOBILERUN_API_BASE_URL": (DEFAULT_API_URL, "default", False), @@ -87,7 +87,7 @@ def resolved_environment(config_path=None): value = is_telemetry_enabled(config_enabled=config.telemetry.enabled) if name in BOOLEAN_VARIABLES and isinstance(value, str): value = value.lower() in TRUTHY - if secret and not value: + if secret and not value and name != "MOBILERUN_API_KEY": source = "not set" if secret: value = "set" if value else "not set" @@ -111,6 +111,8 @@ def env(config_path, verbose, json_output): value = str(value).lower() elif value is None: value = "" - show_source = not (value == "not set" and row["source"] == "not set") - source = f" ({row['source']})" if show_source else "" + 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/options.py b/mobilerun/cli/options.py index c999906e..c2b2fdd9 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -156,14 +156,14 @@ def clean(value): text = f"{message} (HTTP {status})" if status is not None else message hint = None if status in {401, 403}: - from mobilerun.cli.selection import resolve_cloud_credential + 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 = "env key" if credential.source == "env" else "saved sign-in" + 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": diff --git a/mobilerun/cli/selection.py b/mobilerun/cli/selection.py index a3437925..7a40a3ef 100644 --- a/mobilerun/cli/selection.py +++ b/mobilerun/cli/selection.py @@ -38,6 +38,9 @@ def local_adb_errors(enabled=True): 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) diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index f94ccab7..e0cb9193 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -1127,7 +1127,7 @@ def test_cloud_status_json_names_active_credential(cloud_status, cloud_credentia "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.label, + "cloud_credential": cloud_credential_case.source, "env_overrides_login": cloud_credential_case.overrides, } assert "planted-env-key" not in result.output @@ -1153,7 +1153,7 @@ def test_env_json_uses_cloud_credential_source(cloud_credential_case): assert row == { "variable": "MOBILERUN_API_KEY", "value": "set", - "source": cloud_credential_case.env_source, + "source": cloud_credential_case.source, } assert "planted-env-key" not in result.output assert "planted-saved-token" not in result.output @@ -1162,7 +1162,7 @@ def test_env_json_uses_cloud_credential_source(cloud_credential_case): 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.env_source})\n" in result.stdout + 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 From ca36c0807fc68391e40bdeba433fff4865f1d6ee Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:13:59 +0100 Subject: [PATCH 44/59] fix(cli): preserve qr contrast --- mobilerun/cli/auth.py | 13 +++++++------ mobilerun/cli/qr.py | 4 ++-- tests/test_cli_qr.py | 20 +++++++++++++++++--- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py index e1f06793..33b5e51b 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -91,7 +91,7 @@ def _run_grok_oauth_login(credential_path: str, model: str | None = None, **kwar DEFAULT_DEVICE_CLIENT_ID = "mobilerun-cli" -def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool, browser: bool = False): +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 @@ -102,12 +102,12 @@ def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool, browser: 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, color="NO_COLOR" not in os.environ) - hyperlink = terminal and "NO_COLOR" not in os.environ - display = f"\033]8;;{link}\033\\{link}\033]8;;\033\\" if hyperlink else link - click.echo(display, err=True, color=hyperlink) + 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: @@ -115,7 +115,8 @@ def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool, browser: webbrowser.open(link) except Exception: pass - console.print(f"[dim]Waiting for authorization (expires in {code.expires_in}s)...[/]") + 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) diff --git a/mobilerun/cli/qr.py b/mobilerun/cli/qr.py index 4166b39c..f3d592bd 100644 --- a/mobilerun/cli/qr.py +++ b/mobilerun/cli/qr.py @@ -6,7 +6,7 @@ from io import StringIO -def print_qr(data: str, *, color: bool = True) -> bool: +def print_qr(data: str) -> bool: """Print a compact QR with its quiet zone to terminal stderr.""" import segno @@ -18,7 +18,7 @@ def print_qr(data: str, *, color: bool = True) -> bool: 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" if color else line + "\n") + sys.stderr.write(f"\x1b[97;40m{line}\x1b[0m\n") except Exception: return False return True diff --git a/tests/test_cli_qr.py b/tests/test_cli_qr.py index 16d6559e..46d0288d 100644 --- a/tests/test_cli_qr.py +++ b/tests/test_cli_qr.py @@ -7,6 +7,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, Mock +import click import pytest import segno from click.testing import CliRunner @@ -103,7 +104,7 @@ def test_login_prints_complete_link_qr_and_preserves_flow(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", color=True) + 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) @@ -525,12 +526,13 @@ def test_mobilerun_plain_output_and_no_qr(login, monkeypatch, terminal, no_color assert link in result.stderr assert result.stderr.index(link) < result.stderr.index("Code: ABCD-EFGH") if not terminal or no_color: - assert "\x1b" not in result.stderr + 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, color=not no_color) + login.printed.assert_called_once_with(link) login.browser.assert_not_called() @@ -561,3 +563,15 @@ def test_provider_browser_behavior_unchanged(monkeypatch, no_browser): 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 From 60ee06cddf922d33c6f7d2680b8684453da11e39 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:15:04 +0100 Subject: [PATCH 45/59] fix(cli): simplify command suggestions --- mobilerun/cli/options.py | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index c2b2fdd9..478ce0a2 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -214,7 +214,7 @@ def parse_args(self, ctx, args): return super().parse_args(ctx, args) except click.NoSuchOption as exc: if self.name in {"tap", "type", "swipe", "long-press"}: - exc.message += " Put `--` before arguments that start with a dash." + exc.message += "\nPut `--` before arguments that start with a dash." raise def get_params(self, ctx): @@ -310,8 +310,8 @@ def invoke(self, ctx): } _RENAMED_COMMANDS = { - "devices": "mobilerun device list", - "whoami": "mobilerun auth status", + "devices": ("mobilerun", "device", "list"), + "whoami": ("mobilerun", "auth", "status"), } @@ -324,6 +324,15 @@ def _command_paths(command, path=("mobilerun",)): 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 @@ -347,35 +356,27 @@ def resolve_command(self, ctx, args): if not ctx.resilient_parsing and args and exc.message.startswith("No such command"): matches = self.suggest_commands(ctx, args[0]) if matches: - paths = {" ".join(path): command for path, command in - _command_paths(ctx.find_root().command)} - suggestions = [] - for match in matches: - command = paths.get(match) - for remaining in args[1:]: - if not isinstance(command, click.Group) or remaining not in command.commands: - break - match += " " + remaining - command = command.commands[remaining] - suggestions.append(f"'{match}'") - suggestions = ", ".join(suggestions) + 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 = [" ".join(path) for path, _ in paths if path[-1] == name] + exact = [(path, command) for path, command in paths if path[-1] == name] if exact: return exact if name in _RENAMED_COMMANDS: - return [_RENAMED_COMMANDS[name]] + 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 [" ".join((*parent_path, siblings[0]))] + 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(" ".join(path) for path, _ in paths if path[-1] == closest[0])] + return [next((path, command) for path, command in paths if path[-1] == closest[0])] return [] From d43e1e9db81619722ba2c3edab229dc5033dcc0a Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:15:24 +0100 Subject: [PATCH 46/59] fix(cli): parse removed flags --- mobilerun/cli/device_commands.py | 8 +++--- mobilerun/cli/device_lifecycle.py | 3 ++- mobilerun/cli/options.py | 43 ++++++++++++++++--------------- tests/test_cli_fix4.py | 17 ++++++++++++ 4 files changed, 45 insertions(+), 26 deletions(-) diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index 65314c93..8386e05d 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -165,7 +165,7 @@ def ui(json_output, all_elements, **params): 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) @click.argument("x", type=int) @click.argument("y", type=int) @device_options @@ -176,7 +176,7 @@ def tap(x, y, **params): click.echo(f"Tapped ({x}, {y})", err=True) -@device_cli.command("swipe") +@device_cli.command("swipe", dash_hint=True) @click.argument("x1", type=int) @click.argument("y1", type=int) @click.argument("x2", type=int) @@ -190,7 +190,7 @@ def swipe_cmd(x1, y1, x2, y2, duration, **params): click.echo(f"Swiped ({x1}, {y1}) -> ({x2}, {y2})", err=True) -@device_cli.command("long-press") +@device_cli.command("long-press", dash_hint=True) @click.argument("x", type=int) @click.argument("y", type=int) @device_options @@ -201,7 +201,7 @@ def long_press(x, y, **params): click.echo(f"Long pressed ({x}, {y})", err=True) -@device_cli.command("type") +@device_cli.command("type", dash_hint=True) @click.argument("text") @click.option("--clear", is_flag=True, help="Clear field before typing.") @device_options diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index a975ad43..3b903c23 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -10,6 +10,7 @@ from mobilerun.cli.options import ( NO_CLOUD_DEVICES_HINT, NO_LOCAL_DEVICES_HINT, + Command, api_url_option, config_option, convert_api_error, @@ -113,7 +114,7 @@ async def disconnect(serial, verbose): ) -@device_cli.command() +@device_cli.command(removed_flags={**Command.removed_flags, "--path": "--apk"}) @device_option @config_option @verbose_option diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index 478ce0a2..eab81128 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -190,30 +190,38 @@ def clean(value): 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.""" - def __init__(self, *args, **kwargs): + 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): - if not ctx.resilient_parsing: - for arg in args: - if arg == "--": - break - flag = arg.split("=", 1)[0] - replacement = _REMOVED_FLAGS.get(flag) - if flag == "--path" and self.name == "setup": - replacement = "--apk" - if replacement: - raise click.UsageError( - f"Removed option: {flag}. Use {replacement} instead.", ctx - ) + legacy_cluster = "-pv" in args try: return super().parse_args(ctx, args) except click.NoSuchOption as exc: - if self.name in {"tap", "type", "swipe", "long-press"}: + 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 @@ -302,13 +310,6 @@ def invoke(self, ctx): logger.setLevel(level) -_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", -} - _RENAMED_COMMANDS = { "devices": ("mobilerun", "device", "list"), "whoami": ("mobilerun", "auth", "status"), diff --git a/tests/test_cli_fix4.py b/tests/test_cli_fix4.py index b86dbe9f..bffea8c2 100644 --- a/tests/test_cli_fix4.py +++ b/tests/test_cli_fix4.py @@ -251,3 +251,20 @@ def test_doctor_reports_library_adb_error(monkeypatch): 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 From 3eb9f26ae1299b1a4edc0db666c244ddca15d263 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:15:50 +0100 Subject: [PATCH 47/59] fix(cli): centralize config paths --- mobilerun/cli/options.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index eab81128..01e7027a 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -237,12 +237,23 @@ 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 "verbose" in ctx.params and ctx.params["verbose"] is None: + 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: @@ -259,22 +270,9 @@ def invoke(self, ctx): # Libraries and OAuth flows may print progress directly. with redirect_stdout(sys.stderr): if ctx.command.name != "completion": - from mobilerun.config_manager import ConfigLoader - - try: - path = ConfigLoader.resolve_path(ctx.params.get("config_path")) - except (OSError, ValueError): - if ctx.command.name != "doctor": - raise - path = ConfigLoader.get_user_config_path() - else: - from pathlib import Path - - from mobilerun.config_manager.credential_paths import USER_CONFIG_DIR - - path = Path(os.environ.get("MOBILERUN_CONFIG", USER_CONFIG_DIR / "config.yaml")) - if ctx.params.get("verbose") is None: - ctx.params["verbose"] = config_debug(path) + 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): From cdcbeb1076f4d89469e2a1f0ee309883d3249555 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:16:41 +0100 Subject: [PATCH 48/59] fix(cli): declare command examples --- mobilerun/cli/auth.py | 6 +++--- mobilerun/cli/completion.py | 4 ++-- mobilerun/cli/device_commands.py | 16 ++++++++-------- mobilerun/cli/device_lifecycle.py | 8 ++++---- mobilerun/cli/doctor_command.py | 3 ++- mobilerun/cli/env.py | 3 ++- mobilerun/cli/tree.py | 29 ----------------------------- mobilerun/macro/cli.py | 4 ++-- 8 files changed, 23 insertions(+), 50 deletions(-) diff --git a/mobilerun/cli/auth.py b/mobilerun/cli/auth.py index 33b5e51b..e80ffa39 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -178,7 +178,7 @@ def _login_mobilerun(auth_url: str | None, client_id: str, no_qr: bool, browser: ) -@click.group(cls=Group) +@click.group(cls=Group, epilog=examples('mobilerun auth status')) @verbose_option def auth_cli(verbose): """Sign in, sign out, and inspect saved credentials.""" @@ -307,7 +307,7 @@ def login( }[service](**kwargs) -@auth_cli.command() +@auth_cli.command( epilog=examples('mobilerun auth logout')) @service_argument @verbose_option def logout(service, verbose): @@ -330,7 +330,7 @@ def logout(service, verbose): console.print(f"{credential.env_var} stays active; unset it to stop using the env key.") -@auth_cli.command() +@auth_cli.command( epilog=examples('mobilerun auth status -j')) @verbose_option @json_option def status(verbose, json_output): diff --git a/mobilerun/cli/completion.py b/mobilerun/cli/completion.py index c74b6757..b9e3c501 100644 --- a/mobilerun/cli/completion.py +++ b/mobilerun/cli/completion.py @@ -3,10 +3,10 @@ import click from click.shell_completion import get_completion_class -from mobilerun.cli.options import Command, emit, verbose_option +from mobilerun.cli.options import Command, emit, examples, verbose_option -@click.command(cls=Command) +@click.command(cls=Command, epilog=examples("mobilerun completion bash")) @click.argument("shell", type=click.Choice(["bash", "zsh", "fish"])) @verbose_option def completion(shell, verbose): diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index 8386e05d..50b31eb1 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -72,7 +72,7 @@ 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 def screenshot(json_output, **params): @@ -165,7 +165,7 @@ def ui(json_output, all_elements, **params): click.echo(f"App: {_ui_cell(app)}; screen: {_ui_cell(width)}x{_ui_cell(height)}", err=True) -@device_cli.command(dash_hint=True) +@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 @@ -176,7 +176,7 @@ def tap(x, y, **params): click.echo(f"Tapped ({x}, {y})", err=True) -@device_cli.command("swipe", dash_hint=True) +@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) @@ -190,7 +190,7 @@ def swipe_cmd(x1, y1, x2, y2, duration, **params): click.echo(f"Swiped ({x1}, {y1}) -> ({x2}, {y2})", err=True) -@device_cli.command("long-press", dash_hint=True) +@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 @@ -201,7 +201,7 @@ def long_press(x, y, **params): click.echo(f"Long pressed ({x}, {y})", err=True) -@device_cli.command("type", dash_hint=True) +@device_cli.command("type", dash_hint=True, epilog=examples('mobilerun device type "Hello"')) @click.argument("text") @click.option("--clear", is_flag=True, help="Clear field before typing.") @device_options @@ -212,7 +212,7 @@ def type_text(text, clear, **params): 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 def press(button, **params): @@ -222,7 +222,7 @@ def press(button, **params): click.echo(f"Pressed {button}", err=True) -@device_cli.command() +@device_cli.command( epilog=examples('mobilerun device apps -j')) @click.option("--system", is_flag=True, help="Include system apps.") @json_option @device_options @@ -241,7 +241,7 @@ def apps(system, json_output, **params): )) -@device_cli.command() +@device_cli.command( epilog=examples('mobilerun device start com.android.settings')) @click.argument("package") @device_options def start(package, **params): diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index 3b903c23..378e39a5 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -87,7 +87,7 @@ def _list_target(api_url, scope): resource.close() -@device_cli.command() +@device_cli.command( epilog=examples('mobilerun device connect 192.0.2.1:5555')) @click.argument("serial", metavar="HOST:PORT") @verbose_option @coro @@ -100,7 +100,7 @@ async def connect(serial, verbose): click.echo(f"Successfully connected to {serial}", err=True) -@device_cli.command() +@device_cli.command( epilog=examples('mobilerun device disconnect 192.0.2.1:5555')) @click.argument("serial", metavar="HOST:PORT") @verbose_option @coro @@ -114,7 +114,7 @@ async def disconnect(serial, verbose): ) -@device_cli.command(removed_flags={**Command.removed_flags, "--path": "--apk"}) +@device_cli.command(removed_flags={**Command.removed_flags, "--path": "--apk"}, epilog=examples('mobilerun device setup -d emulator-5554')) @device_option @config_option @verbose_option @@ -135,7 +135,7 @@ async def setup(device, config_path, verbose, apk, portal_version, latest): raise click.ClickException("Portal setup failed; run `mobilerun doctor` for diagnostics") -@device_cli.command() +@device_cli.command( epilog=examples('mobilerun device ping -d emulator-5554')) @device_option @target_option @config_option diff --git a/mobilerun/cli/doctor_command.py b/mobilerun/cli/doctor_command.py index 63843b81..19aed28c 100644 --- a/mobilerun/cli/doctor_command.py +++ b/mobilerun/cli/doctor_command.py @@ -9,12 +9,13 @@ coro, device_options, emit, + examples, json_option, local_device_hint, ) -@click.command(cls=Command) +@click.command(cls=Command, epilog=examples('mobilerun doctor -j')) @device_options @json_option @coro diff --git a/mobilerun/cli/env.py b/mobilerun/cli/env.py index 04c2fe2a..1195f519 100644 --- a/mobilerun/cli/env.py +++ b/mobilerun/cli/env.py @@ -13,6 +13,7 @@ Command, config_option, emit, + examples, json_option, verbose_option, ) @@ -95,7 +96,7 @@ def resolved_environment(config_path=None): return rows -@click.command(cls=Command) +@click.command(cls=Command, epilog=examples('mobilerun env -j')) @config_option @verbose_option @json_option diff --git a/mobilerun/cli/tree.py b/mobilerun/cli/tree.py index ade44620..2834140f 100644 --- a/mobilerun/cli/tree.py +++ b/mobilerun/cli/tree.py @@ -48,32 +48,3 @@ def cli(verbose): cli.add_command(env) cli.add_command(completion) - -_EXAMPLES = { - "device screenshot": "mobilerun device screenshot -d emulator-5554", - "device tap": "mobilerun device tap 100 200", - "device swipe": "mobilerun device swipe 100 500 100 100", - "device long-press": "mobilerun device long-press 100 200", - "device type": 'mobilerun device type "Hello"', - "device press": "mobilerun device press home", - "device apps": "mobilerun device apps -j", - "device start": "mobilerun device start com.android.settings", - "device connect": "mobilerun device connect 192.0.2.1:5555", - "device disconnect": "mobilerun device disconnect 192.0.2.1:5555", - "device setup": "mobilerun device setup -d emulator-5554", - "device ping": "mobilerun device ping -d emulator-5554", - "auth": "mobilerun auth status", - "auth status": "mobilerun auth status -j", - "auth logout": "mobilerun auth logout", - "doctor": "mobilerun doctor -j", - "env": "mobilerun env -j", - "macro": "mobilerun macro list ./macros", - "macro list": "mobilerun macro list ./macros", - "completion": "mobilerun completion bash", -} -for _path, _example in _EXAMPLES.items(): - _command = cli - for _name in _path.split(): - _command = _command.commands[_name] - if not _command.epilog: - _command.epilog = examples(_example) diff --git a/mobilerun/macro/cli.py b/mobilerun/macro/cli.py index 0f7345c5..604e876d 100644 --- a/mobilerun/macro/cli.py +++ b/mobilerun/macro/cli.py @@ -50,7 +50,7 @@ def configure_logging(debug: bool = False): return logger -@click.group(cls=Group) +@click.group(cls=Group, epilog=examples('mobilerun macro list ./macros')) @verbose_option def macro_cli(verbose): """Replay recorded automation sequences.""" @@ -290,7 +290,7 @@ async def _show_dry_run( 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") @verbose_option @json_option From 59b17f2587a76b0b7b7a12a7aaed5cfbbf5e1dfc Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:17:26 +0100 Subject: [PATCH 49/59] fix(cli): simplify shared policies --- mobilerun/cli/env.py | 26 ++++++++++++++++---------- mobilerun/cli/options.py | 26 +++++++++++++++++--------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/mobilerun/cli/env.py b/mobilerun/cli/env.py index 1195f519..0aa9e131 100644 --- a/mobilerun/cli/env.py +++ b/mobilerun/cli/env.py @@ -75,20 +75,26 @@ def resolved_environment(config_path=None): 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 != "MOBILERUN_API_KEY" and os.environ.get(name) - and not (name == "MOBILERUN_CONFIG" and path_source == "flag")): - value, source = os.environ[name], "env" - if (name == "MOBILERUN_DEBUG" and ctx and - any(parent.get_parameter_source("verbose") == click.core.ParameterSource.COMMANDLINE - for parent in (ctx, ctx.find_root()))): - value, source = ctx.params.get("verbose"), "flag" - if name == "MOBILERUN_TELEMETRY_ENABLED": - value = is_telemetry_enabled(config_enabled=config.telemetry.enabled) if name in BOOLEAN_VARIABLES and isinstance(value, str): value = value.lower() in TRUTHY - if secret and not value and name != "MOBILERUN_API_KEY": + if secret and not value and source is not None: source = "not set" if secret: value = "set" if value else "not set" diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index 01e7027a..566cdc66 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -19,6 +19,9 @@ 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." @@ -41,8 +44,9 @@ def config_debug(path): try: with path.open(encoding="utf-8") as stream: data = yaml.safe_load(stream) - return data.get("logging", {}).get("debug") is True if isinstance(data, dict) else False - except (OSError, ValueError, AttributeError, yaml.YAMLError): + 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 @@ -59,6 +63,12 @@ def local_device_hint(message, target="local"): 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: @@ -70,11 +80,9 @@ def read_secret(ctx, param, value): default="", show_default=False) if stream.isatty() else stream.readline()).rstrip("\r\n") except (KeyboardInterrupt, click.Abort) as exc: - if isinstance(exc, KeyboardInterrupt) or isinstance( - exc.__cause__ or exc.__context__, KeyboardInterrupt - ): + if _caused_by_interrupt(exc): raise click.exceptions.Exit(130) from None - raise click.ClickException("Input closed; run the command again in a terminal.") 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: @@ -83,7 +91,7 @@ def read_secret(ctx, param, value): def redact(text, secrets): - for secret in sorted({value for value in secrets if value and len(value) >= 8}, key=len, reverse=True): + 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 @@ -278,9 +286,9 @@ def invoke(self, ctx): except (KeyboardInterrupt, asyncio.CancelledError): ctx.exit(130) except (EOFError, click.Abort) as exc: - if isinstance(exc.__cause__ or exc.__context__, KeyboardInterrupt): + if _caused_by_interrupt(exc): ctx.exit(130) - raise click.ClickException("Input closed; run the command again in a terminal.") from None + raise click.ClickException(INPUT_CLOSED) from None except click.exceptions.Exit: raise except Exception as exc: From 065c0d3b7396d9161fa2058867bc58e7404a9703 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:17:27 +0100 Subject: [PATCH 50/59] fix(cli): name runtime tests --- tests/test_cli_devx.py | 8 ++++++++ ...test_cli_fix4.py => test_cli_runtime_policy.py} | 14 -------------- 2 files changed, 8 insertions(+), 14 deletions(-) rename tests/{test_cli_fix4.py => test_cli_runtime_policy.py} (95%) diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index e0cb9193..6c88e977 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -1177,3 +1177,11 @@ def test_api_error_without_credential_suggests_login(monkeypatch, status): 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_fix4.py b/tests/test_cli_runtime_policy.py similarity index 95% rename from tests/test_cli_fix4.py rename to tests/test_cli_runtime_policy.py index bffea8c2..75b9afbd 100644 --- a/tests/test_cli_fix4.py +++ b/tests/test_cli_runtime_policy.py @@ -220,20 +220,6 @@ def test_typo_keeps_remaining_command(): assert "Did you mean 'mobilerun device list'?" in result.stderr -def test_every_command_examples_and_verbose(): - def visit(command, path=()): - yield command, path - if isinstance(command, click.Group): - for name, child in command.commands.items(): - yield from visit(child, (*path, name)) - - for _, path in visit(cli): - result = CliRunner().invoke(cli, [*path, "--help"]) - assert result.exit_code == 0 - assert "Examples:" in result.stdout, path - assert "--verbose" in result.stdout, path - - def test_readme_real_error_examples(): readme = Path("README.md").read_text() assert '{"error":"Resource not found","status":404,"code":null}' in readme From d992f7fc890f8c05b1bb0ae1c7fbcdaba302ec73 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:19:40 +0100 Subject: [PATCH 51/59] fix(cli): align source patches --- tests/test_keyboard_cleanup.py | 2 +- tests/test_telemetry.py | 2 +- tests/test_vision_only_run_cli.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_keyboard_cleanup.py b/tests/test_keyboard_cleanup.py index 204db43b..6f1ccecb 100644 --- a/tests/test_keyboard_cleanup.py +++ b/tests/test_keyboard_cleanup.py @@ -44,7 +44,7 @@ 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("mobilerun.telemetry.print_telemetry_message", lambda **_: None) monkeypatch.setattr(cli_main.console, "print", lambda *a, **kw: None) diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index e8f81266..9cf0fca5 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -354,7 +354,7 @@ 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("mobilerun.telemetry.print_telemetry_message", capture_config) monkeypatch.setattr(cli_main, "_cleanup_android_keyboard", skip_keyboard_cleanup) diff --git a/tests/test_vision_only_run_cli.py b/tests/test_vision_only_run_cli.py index 7e640e91..0a36856b 100644 --- a/tests/test_vision_only_run_cli.py +++ b/tests/test_vision_only_run_cli.py @@ -103,7 +103,7 @@ def run(self): with ( patch( - "mobilerun.cli.mobilerun.config_manager.ConfigLoader.load", + "mobilerun.config_manager.ConfigLoader.load", return_value=MobileConfig(), ), patch("mobilerun.MobileAgent", FakeAgent), @@ -156,7 +156,7 @@ def run(self): with ( patch( - "mobilerun.cli.mobilerun.config_manager.ConfigLoader.load", + "mobilerun.config_manager.ConfigLoader.load", return_value=MobileConfig(), ), patch("mobilerun.MobileAgent", FakeAgent), @@ -211,7 +211,7 @@ def run(self): ) with ( - patch("mobilerun.cli.mobilerun.config_manager.ConfigLoader.load", return_value=config), + patch("mobilerun.config_manager.ConfigLoader.load", return_value=config), patch("mobilerun.MobileAgent", FakeAgent), patch("mobilerun.cli.run.adb.device", AsyncMock()) as adb_device, ): From ece80b9552a5f385ddf1cd92cf9d662806a84299 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:19:49 +0100 Subject: [PATCH 52/59] fix(cli): align adb guidance --- mobilerun/cli/doctor.py | 3 ++- tests/test_android_device_selection.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/mobilerun/cli/doctor.py b/mobilerun/cli/doctor.py index 421a1b54..12dad827 100644 --- a/mobilerun/cli/doctor.py +++ b/mobilerun/cli/doctor.py @@ -7,6 +7,7 @@ from enum import Enum from typing import Any +import click import httpx import requests from async_adbutils import AdbDevice, adb @@ -176,7 +177,7 @@ async def check_adb(debug: bool) -> CheckResult: return CheckResult( "ADB", Status.FAIL, - str(e), + e.message if isinstance(e, click.ClickException) else str(e), ) diff --git a/tests/test_android_device_selection.py b/tests/test_android_device_selection.py index 286df42a..0aa9079d 100644 --- a/tests/test_android_device_selection.py +++ b/tests/test_android_device_selection.py @@ -323,5 +323,7 @@ async def fake_replay(path, device, *args, **kwargs): assert devices == [None] else: assert result.exit_code == 1 - assert "adb server not running" in result.output + from mobilerun.cli.options import ADB_HINT + + assert ADB_HINT in result.output assert devices == [] From f568696f3a6c6f4e5f503dfadca09b183fbce2f0 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Thu, 12 Nov 2026 20:31:33 +0100 Subject: [PATCH 53/59] chore(cli): pin core release --- tests/test_android_portal_http_wiring.py | 3 +++ uv.lock | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_android_portal_http_wiring.py b/tests/test_android_portal_http_wiring.py index 8442ddd6..a3d18316 100644 --- a/tests/test_android_portal_http_wiring.py +++ b/tests/test_android_portal_http_wiring.py @@ -138,6 +138,9 @@ def test_ping_checks_portal_http_without_adb(monkeypatch, no_adb): def connect(self, device_id, **kwargs): created.update(kwargs) class Core: + def close(self): + pass + def ui(self): return {} return Core() diff --git a/uv.lock b/uv.lock index 06effccf..3dc613e8 100644 --- a/uv.lock +++ b/uv.lock @@ -1494,7 +1494,7 @@ 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", extras = ["local"], specifier = ">=1.11.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" }, @@ -1527,14 +1527,14 @@ dev = [ [[package]] name = "mobilerun-core" -version = "1.11.0" +version = "1.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mobilerun-sdk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/b8/e19c3cffcffe27448358f54201f9993a976dfb5f44fb4ab6e6c0ff6c1713/mobilerun_core-1.11.0.tar.gz", hash = "sha256:58f7bf489b30010f863ea018d3d9ceaf11ed1f3892318a517bc237c4765975d8", size = 116310, upload-time = "2026-11-10T00:33:42.125Z" } +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/92/54/6364415dd45443a303cd0d11ceba59e2f9299b8db86dd6382e48c91bf1d0/mobilerun_core-1.11.0-py3-none-any.whl", hash = "sha256:875513f2ad9e5608a0b64f6e9ccb85d60d208f1f87a6d7cc8ae358242ad32598", size = 61017, upload-time = "2026-11-10T00:33:40.851Z" }, + { 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] From bf5cca2fa1c4b008a3c5305540b18e186755bbae Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Fri, 13 Nov 2026 00:24:41 +0100 Subject: [PATCH 54/59] style(cli): apply black --- mobilerun/agent/droid/droid_agent.py | 3 +- mobilerun/agent/fast_agent/fast_agent.py | 3 +- mobilerun/agent/manager/manager_agent.py | 3 +- .../agent/utils/oauth/anthropic_oauth_llm.py | 3 +- .../oauth/gemini_oauth_code_assist_llm.py | 3 +- .../agent/utils/oauth/openai_oauth_llm.py | 3 +- mobilerun/cli/__init__.py | 1 + mobilerun/cli/auth.py | 41 +- mobilerun/cli/configure.py | 11 +- mobilerun/cli/device_commands.py | 101 +++- mobilerun/cli/device_lifecycle.py | 44 +- mobilerun/cli/doctor.py | 30 +- mobilerun/cli/doctor_command.py | 24 +- mobilerun/cli/env.py | 29 +- mobilerun/cli/options.py | 48 +- mobilerun/cli/providers.py | 17 +- mobilerun/cli/run.py | 24 +- mobilerun/cli/selection.py | 37 +- mobilerun/cli/setup.py | 20 +- mobilerun/config_manager/config_manager.py | 11 +- mobilerun/config_manager/loader.py | 4 +- mobilerun/macro/cli.py | 51 +- tests/test_android_device_selection.py | 4 + tests/test_android_portal_http_wiring.py | 18 +- tests/test_cli_contract.py | 537 ++++++++++++++---- tests/test_cli_exit_codes.py | 28 +- tests/test_cli_polish_fix2.py | 303 +++++++--- tests/test_cloud_cli.py | 21 +- tests/test_core_local_runtime.py | 16 +- tests/test_gemini_oauth_llm.py | 4 +- tests/test_grok_cli.py | 6 +- tests/test_ios_local_portal_wiring.py | 39 +- tests/test_state_fetch_retry_policy.py | 4 +- tests/test_vision_only_run_cli.py | 7 +- 34 files changed, 1163 insertions(+), 335 deletions(-) diff --git a/mobilerun/agent/droid/droid_agent.py b/mobilerun/agent/droid/droid_agent.py index 90a112ff..c97c6b07 100644 --- a/mobilerun/agent/droid/droid_agent.py +++ b/mobilerun/agent/droid/droid_agent.py @@ -407,8 +407,7 @@ def __init__( self._using_external_agent = self.config.agent.name not in _BUILTIN_AGENT_NAMES self._stream_screenshots = ( - os.environ.get("MOBILERUN_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 94752c19..01115dc2 100644 --- a/mobilerun/agent/fast_agent/fast_agent.py +++ b/mobilerun/agent/fast_agent/fast_agent.py @@ -160,8 +160,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("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 722d3103..5a148c06 100644 --- a/mobilerun/agent/manager/manager_agent.py +++ b/mobilerun/agent/manager/manager_agent.py @@ -107,8 +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("MOBILERUN_STREAM_SCREENSHOTS") or "" ).lower() in ("1", "true") self.shared_state = shared_state self.registry = registry diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index a831ef19..2e29fa03 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -530,8 +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("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 1514c590..c9a2ddbf 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -604,8 +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("MOBILERUN_OAUTH_MANUAL") or "" ).lower() in ("1", "true", "yes") if use_authcode: return self.login_headless( diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index 8dd4339b..c662d8fa 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -776,8 +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("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 791480ff..024cb75f 100644 --- a/mobilerun/cli/__init__.py +++ b/mobilerun/cli/__init__.py @@ -6,5 +6,6 @@ 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 index 8a5bc5b8..894e8885 100644 --- a/mobilerun/cli/auth.py +++ b/mobilerun/cli/auth.py @@ -7,7 +7,10 @@ from rich.console import Console from mobilerun.cli import deviceauth -from mobilerun.cli.configure_wizard import _OAUTH_CREDENTIAL_SLOTS, _oauth_credentials_present +from mobilerun.cli.configure_wizard import ( + _OAUTH_CREDENTIAL_SLOTS, + _oauth_credentials_present, +) from mobilerun.cli.oauth_actions import ( run_anthropic_setup_token_oauth, run_gemini_oauth_login, @@ -46,7 +49,9 @@ def _validate_oauth_timeout( 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()}") + console.print( + f"[blue]Credentials saved to:[/] {Path(credential_path).expanduser()}" + ) def _run_openai_oauth_login(credential_path: str, model: str | None, **kwargs) -> None: @@ -69,7 +74,9 @@ def _run_anthropic_oauth_login(credential_path: str, **kwargs) -> None: _print_oauth_login_success("Anthropic", credential_path) -def _run_grok_oauth_login(credential_path: str, model: str | None = None, **kwargs) -> None: +def _run_grok_oauth_login( + credential_path: str, model: str | None = None, **kwargs +) -> None: run_grok_oauth_login( credential_path=credential_path, model=model, @@ -99,7 +106,9 @@ def _login_mobilerun(auth_url: str | None, client_id: str): webbrowser.open(code.verification_uri_complete) except Exception: pass - console.print(f"[dim]Waiting for authorization (expires in {code.expires_in}s)...[/]") + console.print( + f"[dim]Waiting for authorization (expires in {code.expires_in}s)...[/]" + ) try: token = deviceauth.poll_token(auth_url, client_id, code) @@ -172,9 +181,13 @@ def auth_cli(verbose): @verbose_option @auth_url_option @model_option -@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." + "--client-id", default=None, help="Mobilerun device authorization client id." +) +@click.option( + "--credential-path", + type=click.Path(dir_okay=False), + help="Provider credential file.", ) @click.option( "--timeout", @@ -185,7 +198,9 @@ def auth_cli(verbose): ) @click.option("--no-browser", is_flag=True, help="Do not open a browser.") @click.option("--callback-host", help="OAuth callback host.") -@click.option("--callback-port", type=click.IntRange(0, 65535), help="OAuth callback port.") +@click.option( + "--callback-port", type=click.IntRange(0, 65535), help="OAuth callback port." +) @click.option("--callback-path", help="OAuth callback path.") @click.option("--device-code", is_flag=True, help="Use xAI device-code login.") @click.option("--token", help="Save an Anthropic setup-token.") @@ -212,9 +227,13 @@ def login( 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}") + raise click.UsageError( + f"--{name.replace('_', '-')} is not supported for {service}" + ) if service == "mobilerun": - _login_mobilerun(auth_url or DEFAULT_AUTH_URL, client_id or DEFAULT_DEVICE_CLIENT_ID) + _login_mobilerun( + auth_url or DEFAULT_AUTH_URL, client_id or DEFAULT_DEVICE_CLIENT_ID + ) return kwargs = { "credential_path": credential_path or str(SERVICES[service][0]), @@ -260,7 +279,9 @@ def logout(service, verbose): 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}.") + console.print( + f"Logged out of {service}." if removed else f"Not logged in to {service}." + ) @auth_cli.command() diff --git a/mobilerun/cli/configure.py b/mobilerun/cli/configure.py index 4fed057f..90dc5f06 100644 --- a/mobilerun/cli/configure.py +++ b/mobilerun/cli/configure.py @@ -9,7 +9,10 @@ _run_grok_oauth_login, _run_openai_oauth_login, ) -from mobilerun.cli.configure_wizard import ConfigureWizardCallbacks, run_configure_wizard +from mobilerun.cli.configure_wizard import ( + ConfigureWizardCallbacks, + run_configure_wizard, +) from mobilerun.cli.options import ( CONTEXT_SETTINGS, Command, @@ -28,7 +31,11 @@ @verbose_option @config_option @click.option("-k", "--api-key", help="API key for the selected provider.") -@click.option("--auth-mode", type=click.Choice(["api_key", "oauth", "none"]), help="Authentication mode for the provider.") +@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): """Configure provider, authentication and model with the wizard.""" spec = PROVIDERS.get(provider) diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py index e950f86e..e97cdb48 100644 --- a/mobilerun/cli/device_commands.py +++ b/mobilerun/cli/device_commands.py @@ -11,10 +11,24 @@ import click from mobilerun.agent.utils.android_device import is_portal_http_url -from mobilerun.cli.options import Group, device_options, emit, json_option, verbose_option +from mobilerun.cli.options import ( + Group, + device_options, + emit, + json_option, + verbose_option, +) from mobilerun.cli.selection import core_client, portal_token, select_device -UI_FLAGS = ("clickable", "checkable", "checked", "editable", "focused", "scrollable", "selected") +UI_FLAGS = ( + "clickable", + "checkable", + "checked", + "editable", + "focused", + "scrollable", + "selected", +) @contextmanager @@ -22,15 +36,21 @@ def device_driver(params=None, *, selection=None): """Resolve Click's device options once and yield the connected core Device.""" 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"), - )) + selection = asyncio.run( + select_device( + device=params.get("device"), + config_path=params.get("config_path"), + platform=params.get("platform"), + target=params.get("target", "local"), + ) + ) token = portal_token(selection) client = core_client(params["api_url"], cloud=selection.backend == "cloud") try: connected = client.connect( - selection.device, backend=selection.backend, platform=selection.platform, + selection.device, + backend=selection.backend, + platform=selection.platform, url=selection.device if is_portal_http_url(selection.device) else None, token=token, ) @@ -68,7 +88,9 @@ def screenshot(json_output, **params): if image.startswith(("/", "./")): path = image else: - with tempfile.NamedTemporaryFile(prefix="mobilerun_", suffix=".png", delete=False) as file: + 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) @@ -94,11 +116,15 @@ def _ltrb(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"], + 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")) + values = tuple( + bounds.get(key, "") for key in ("left", "top", "right", "bottom") + ) return ",".join(_ui_cell(value) for value in values) @@ -106,7 +132,8 @@ 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 + flag + for flag in UI_FLAGS if _field(node, flag, "is" + flag.capitalize(), "is_" + flag) ] if node.get("disabled") or any( @@ -118,16 +145,26 @@ def ui_lines(state, all_elements=False): 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), - )) + 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() @json_option -@click.option("--all", "all_elements", is_flag=True, help="Include empty layout containers.") +@click.option( + "--all", "all_elements", is_flag=True, help="Include empty layout containers." +) @device_options def ui(json_output, all_elements, **params): """Print tab-separated UI elements, or the raw state with --json.""" @@ -138,8 +175,10 @@ def ui(json_output, all_elements, **params): 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): + 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) @@ -149,7 +188,10 @@ def ui(json_output, all_elements, **params): 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) + click.echo( + f"App: {_ui_cell(app)}; screen: {_ui_cell(width)}x{_ui_cell(height)}", + err=True, + ) @device_cli.command() @@ -168,7 +210,13 @@ def tap(x, y, **params): @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.") +@click.option( + "--duration", + type=float, + default=1.0, + show_default=True, + help="Duration in seconds.", +) @device_options def swipe_cmd(x1, y1, x2, y2, duration, **params): """Swipe from (x1, y1) to (x2, y2).""" @@ -200,7 +248,9 @@ def type_text(text, clear, **params): @device_cli.command() -@click.argument("button", type=click.Choice(["back", "home", "enter"], case_sensitive=False)) +@click.argument( + "button", type=click.Choice(["back", "home", "enter"], case_sensitive=False) +) @device_options def press(button, **params): """Press a system button.""" @@ -217,7 +267,10 @@ def apps(system, json_output, **params): """List installed apps.""" with device_driver(params) as device: result = device.list_apps(include_system_apps=system) - emit(result if json_output else json.dumps(result, indent=2), json_output=json_output) + emit( + result if json_output else json.dumps(result, indent=2), + json_output=json_output, + ) @device_cli.command() diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index bf7526a4..21f47aac 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -24,7 +24,9 @@ @api_url_option @verbose_option @json_option -@click.option("--all", "all_devices", is_flag=True, help="List local and cloud devices.") +@click.option( + "--all", "all_devices", is_flag=True, help="List local and cloud devices." +) def list_devices(target, api_url, verbose, json_output, all_devices): """List devices in the selected target.""" scope = "all" if all_devices else target @@ -37,17 +39,23 @@ def list_devices(target, api_url, verbose, json_output, all_devices): # Core seeds this model from the list response, but has no public # metadata accessor for name/state/type on Device yet. metadata = getattr(device._conn, "_device_cache", None) - rows.append({ - "device_id": device.device_id, - "name": metadata.name if metadata else None, - "state": metadata.state if metadata else "connected", - "type": metadata.type if metadata else device.capabilities["backend"], - }) + rows.append( + { + "device_id": device.device_id, + "name": metadata.name if metadata else None, + "state": metadata.state if metadata else "connected", + "type": ( + metadata.type if metadata else device.capabilities["backend"] + ), + } + ) if json_output: emit(rows, json_output=True) elif rows: for row in rows: - emit(f"{row['device_id']} {row['name'] or '-'} {row['state']} {row['type']}") + emit( + f"{row['device_id']} {row['name'] or '-'} {row['state']} {row['type']}" + ) else: click.echo("No devices found.", err=True) finally: @@ -77,8 +85,12 @@ async def disconnect(serial, verbose): """Disconnect an Android TCP/IP device.""" 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, + ( + f"Successfully disconnected from {serial}" + if success + else f"Device {serial} was not connected" + ), + err=True, ) @@ -86,7 +98,9 @@ async def disconnect(serial, verbose): @device_option @config_option @verbose_option -@click.option("--apk", type=click.Path(exists=True, dir_okay=False), help="Portal APK file.") +@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 @@ -97,8 +111,12 @@ async def setup(device, config_path, verbose, apk, portal_version, 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") + 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() diff --git a/mobilerun/cli/doctor.py b/mobilerun/cli/doctor.py index d4ba2dfa..52c45fa8 100644 --- a/mobilerun/cli/doctor.py +++ b/mobilerun/cli/doctor.py @@ -131,7 +131,9 @@ 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, config_path: str | None = None) -> 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 @@ -140,7 +142,12 @@ async def check_config(debug: bool, config_path: str | None = None) -> tuple[Che 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 + return ( + CheckResult( + "Config", Status.PASS, str(path) + ("" if existed else " (created)") + ), + config, + ) except OutdatedConfigError: return ( CheckResult( @@ -652,7 +659,10 @@ async def run_doctor( device_serial: str | None = None, debug: bool = False, config_path: str | None = None, - *, target="local", platform=None, api_url=None, + *, + target="local", + platform=None, + api_url=None, ) -> list[CheckResult]: """Run all doctor checks and auto-fix where possible.""" debug = debug or False @@ -683,8 +693,12 @@ async def run_doctor( 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))) + 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) @@ -697,7 +711,11 @@ async def run_doctor( if non_adb: try: selection = await select_device( - device_serial, config_path, platform=selected_platform, target=target, config=config, + 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") diff --git a/mobilerun/cli/doctor_command.py b/mobilerun/cli/doctor_command.py index 08f84fe5..edc098f0 100644 --- a/mobilerun/cli/doctor_command.py +++ b/mobilerun/cli/doctor_command.py @@ -5,7 +5,14 @@ import click from mobilerun.cli.doctor import Status, run_doctor -from mobilerun.cli.options import CONTEXT_SETTINGS, Command, coro, device_options, emit, json_option +from mobilerun.cli.options import ( + CONTEXT_SETTINGS, + Command, + coro, + device_options, + emit, + json_option, +) @click.command(cls=Command, context_settings=CONTEXT_SETTINGS) @@ -14,8 +21,14 @@ @coro async def doctor(device, config_path, platform, target, api_url, verbose, json_output): """Check system and device health; fail if any check fails.""" - results = await run_doctor(device, bool(verbose), config_path, - target=target, platform=platform, api_url=api_url) + results = await run_doctor( + device, + bool(verbose), + config_path, + target=target, + platform=platform, + api_url=api_url, + ) failed = any(result.status == Status.FAIL for result in results) if not json_output: for result in results: @@ -23,4 +36,7 @@ async def doctor(device, config_path, platform, target, api_url, verbose, json_o if failed: raise click.ClickException("Health checks failed") if json_output: - emit([{**asdict(result), "status": result.status.value} for result in results], json_output=True) + emit( + [{**asdict(result), "status": result.status.value} for result in results], + json_output=True, + ) diff --git a/mobilerun/cli/env.py b/mobilerun/cli/env.py index 00e0a5dd..6de02bba 100644 --- a/mobilerun/cli/env.py +++ b/mobilerun/cli/env.py @@ -29,15 +29,24 @@ def resolved_environment(config_path=None): 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) + "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_key = resolve_cloud_api_key() 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_DEVICE": ( + config.device.serial, + config_source if config.device.serial else "not set", + False, + ), "MOBILERUN_CLOUD_API_KEY": ( cloud_key, "credential file" if cloud_key else "not set", @@ -61,17 +70,25 @@ def resolved_environment(config_path=None): } 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" + 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) + 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) rows = [] for name, (value, source, secret) in values.items(): - if os.environ.get(name) and not (name == "MOBILERUN_CONFIG" and path_source == "flag"): + if os.environ.get(name) and not ( + name == "MOBILERUN_CONFIG" and path_source == "flag" + ): value, source = os.environ[name], "env" if name == "MOBILERUN_TELEMETRY_ENABLED": value = is_telemetry_enabled(config_enabled=config.telemetry.enabled) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index e8bd6804..f72ecc69 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -42,12 +42,29 @@ def clean(value): 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 + 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) + 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 @@ -56,7 +73,11 @@ def clean(value): 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} + 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}: @@ -64,7 +85,9 @@ def clean(value): elif status == 404 and "devices" in request.url.path.split("/"): hint = "Find device ids with `mobilerun device list -t cloud`." elif status is None: - hint = f"Check the API URL: {clean(request.url.scheme + '://' + request.url.host)}" + hint = ( + f"Check the API URL: {clean(request.url.scheme + '://' + request.url.host)}" + ) if hint: text += f"\n{hint}" diagnostic = f"{request.method} {clean(request.url.path)}" @@ -89,7 +112,9 @@ def invoke(self, ctx): logger = logging.getLogger("mobilerun") previous = logger.handlers[:], logger.level, logger.propagate - configure_logging(bool(ctx.params.get("verbose")), logging.StreamHandler(sys.stderr)) + configure_logging( + bool(ctx.params.get("verbose")), logging.StreamHandler(sys.stderr) + ) try: # Libraries and OAuth flows may print progress directly. with redirect_stdout(sys.stderr): @@ -113,8 +138,11 @@ def invoke(self, ctx): traceback.print_exc(file=sys.stderr) exc = click.ClickException(str(exc) or type(exc).__name__) document = {"error": exc.message} - if (not isinstance(exc, click.UsageError) and ctx.params.get("json_output") - and not ctx.obj["emitted"]): + 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: diff --git a/mobilerun/cli/providers.py b/mobilerun/cli/providers.py index 37ccdc23..64056366 100644 --- a/mobilerun/cli/providers.py +++ b/mobilerun/cli/providers.py @@ -16,7 +16,11 @@ class Provider: @property def runtime(self): - return resolve_provider_variant(self.family, self.auth_mode).id if self.family else self.run_only + return ( + resolve_provider_variant(self.family, self.auth_mode).id + if self.family + else self.run_only + ) PROVIDERS = { @@ -35,7 +39,10 @@ def runtime(self): "deepseek": Provider(None, "api_key", "base_url", "DeepSeek"), } provider_option = click.option( - "-p", "--provider", type=click.Choice(list(PROVIDERS), case_sensitive=False), help="LLM provider.", + "-p", + "--provider", + type=click.Choice(list(PROVIDERS), case_sensitive=False), + help="LLM provider.", ) @@ -50,4 +57,8 @@ def llm_url_kwargs(runtime, 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} + return { + spec.url_keyword: ( + {"base_url": url} if spec.url_keyword == "http_options" else url + ) + } diff --git a/mobilerun/cli/run.py b/mobilerun/cli/run.py index 96805f83..860dbb0b 100644 --- a/mobilerun/cli/run.py +++ b/mobilerun/cli/run.py @@ -88,7 +88,9 @@ async def _run_task( 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_text.append( + "https://cloud.mobilerun.ai/sign-in", style="bold blue underline" + ) cloud_panel = Panel( cloud_text, border_style="cyan", @@ -151,7 +153,9 @@ async def _run_task( if device_id is not None: config.device.device_id = device_id - if (config.device.control_backend or "").strip().lower() == VISUAL_REMOTE_CONNECTION: + 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") @@ -181,7 +185,9 @@ async def _run_task( if selection.backend == "cloud": is_cloud = True if tcp: - raise click.UsageError("--tcp is only supported for local Android devices") + 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" @@ -194,7 +200,9 @@ async def _run_task( # STEP 2: Initialize MobileAgent with config # ================================================================ - mode = "planning with reasoning" if config.agent.reasoning else "direct execution" + 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}, " @@ -249,7 +257,9 @@ async def _run_task( finally: if not is_cloud: - await _cleanup_android_keyboard(config, getattr(droid_agent, "android_serial", None)) + await _cleanup_android_keyboard( + config, getattr(droid_agent, "android_serial", None) + ) async def run_command(*args, **kwargs) -> bool: @@ -261,7 +271,9 @@ async def run_command(*args, **kwargs) -> bool: return False -async def _cleanup_android_keyboard(config: MobileConfig, serial: str | None = None) -> None: +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: diff --git a/mobilerun/cli/selection.py b/mobilerun/cli/selection.py index 5c0a2cab..9f25a33f 100644 --- a/mobilerun/cli/selection.py +++ b/mobilerun/cli/selection.py @@ -33,7 +33,8 @@ def resolve_cloud_api_key(): def core_client(api_url=DEFAULT_API_URL, *, cloud=False): return mobilerun_core.Mobilerun( - bearer=require_cloud_api_key() if cloud else resolve_cloud_api_key(), base_url=api_url + bearer=require_cloud_api_key() if cloud else resolve_cloud_api_key(), + base_url=api_url, ) @@ -117,17 +118,30 @@ async def _native_driver(selection, config, api_url): from mobilerun.agent.utils.portal_setup import portal_version_kwargs if selection.backend == "cloud": - driver = CloudDriver(device_id=selection.device, api_key=require_cloud_api_key(), base_url=api_url) + 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)) + 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)) + 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 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 @@ -135,10 +149,15 @@ async def _native_driver(selection, config, api_url): 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 + 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)}") + 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 index 9782e247..2d43aef5 100644 --- a/mobilerun/cli/setup.py +++ b/mobilerun/cli/setup.py @@ -74,7 +74,9 @@ async def _setup_portal( device_obj = await adb.device(device) if not device_obj: - console.print(f"[bold red]Error:[/] Could not get device object for {device}") + console.print( + f"[bold red]Error:[/] Could not get device object for {device}" + ) return False # CLI-specific options: path, specific_version, latest @@ -101,7 +103,9 @@ async def _setup_portal( "\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.") + console.print( + "[bold red]Setup failed.[/] Run 'mobilerun doctor' for diagnostics." + ) return bool(success) # Install from explicit path/version/latest @@ -145,14 +149,20 @@ async def _setup_portal( "[yellow]Opening accessibility settings for manual configuration...[/]" ) - await device_obj.shell("am start -a android.settings.ACCESSIBILITY_SETTINGS") + 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( + "\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( + "3. Toggle the switch to [bold]ON[/] to enable the service" + ) console.print("4. Accept any permission dialogs that appear") console.print( diff --git a/mobilerun/config_manager/config_manager.py b/mobilerun/config_manager/config_manager.py index 696c361c..f9b8bd39 100644 --- a/mobilerun/config_manager/config_manager.py +++ b/mobilerun/config_manager/config_manager.py @@ -5,7 +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_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 ( @@ -195,7 +198,11 @@ def resolve_auth_token(self, platform: str | None = None) -> Optional[str]: 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 + configured = ( + self.auth_token + if requested.lower() == (self.platform or "").lower() + else None + ) return os.environ.get(name) or configured diff --git a/mobilerun/config_manager/loader.py b/mobilerun/config_manager/loader.py index c9493a5e..bd8b581a 100644 --- a/mobilerun/config_manager/loader.py +++ b/mobilerun/config_manager/loader.py @@ -91,7 +91,9 @@ 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, Path(path) if path is not None else 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 c2a599e1..22418a8e 100644 --- a/mobilerun/macro/cli.py +++ b/mobilerun/macro/cli.py @@ -22,7 +22,11 @@ verbose_option, ) from mobilerun.cli.providers import PROVIDERS, provider_option -from mobilerun.cli.selection import _native_driver, select_device, teardown_native_driver +from mobilerun.cli.selection import ( + _native_driver, + select_device, + teardown_native_driver, +) from mobilerun.config_manager.path_resolver import PathResolver from mobilerun.macro.replay import MacroPlayer @@ -68,7 +72,11 @@ def macro_cli(verbose): ) @max_steps_option @click.option( - "-n", "--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", @@ -108,7 +116,10 @@ async def replay( config_path: Optional[str], provider: Optional[str], model: Optional[str], - target, platform, api_url, tcp, + target, + platform, + api_url, + tcp, ): """Replay a macro from a file or trajectory folder.""" logger = configure_logging(bool(verbose)) @@ -120,11 +131,27 @@ async def replay( # Convert start_from from 1-based to 0-based start_from_zero = max(0, start_from - 1) - selection = None if dry_run else await select_device(device, config_path, tcp, platform, target) + selection = ( + None + if dry_run + else await select_device(device, config_path, tcp, platform, target) + ) await _replay_async( - path, selection.device if selection else device, delay, start_from_zero, - max_steps, dry_run, logger, on_mismatch, state_timeout, state_threshold, - config_path, provider, model, selection=selection, api_url=api_url, + path, + selection.device if selection else device, + delay, + start_from_zero, + max_steps, + dry_run, + logger, + on_mismatch, + state_timeout, + state_threshold, + config_path, + provider, + model, + selection=selection, + api_url=api_url, ) @@ -222,7 +249,6 @@ async def _replay_async( raise click.ClickException("Macro replay failed") - async def _show_dry_run( macro_data: dict, start_from: int, max_steps: Optional[int], logger: logging.Logger ): @@ -309,8 +335,13 @@ def list(directory: str, verbose: bool, json_output: bool): 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) + 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") diff --git a/tests/test_android_device_selection.py b/tests/test_android_device_selection.py index cca64d94..f79632f1 100644 --- a/tests/test_android_device_selection.py +++ b/tests/test_android_device_selection.py @@ -167,6 +167,7 @@ async def fake_setup(*args, **kwargs): 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 + _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}): @@ -178,11 +179,14 @@ def test_device_command_uses_single_online_device(adb_devices, monkeypatch): from mobilerun.cli.device_commands import device_driver from mobilerun.cli.options import DEFAULT_API_URL + _set(adb_devices, ("emulator-5556", "unauthorized"), ("emulator-5554", "device")) serials = [] + def connect(self, serial, **kwargs): serials.append(serial) return object() + monkeypatch.setattr(mobilerun_core.Mobilerun, "connect", connect) with device_driver({"api_url": DEFAULT_API_URL}): pass diff --git a/tests/test_android_portal_http_wiring.py b/tests/test_android_portal_http_wiring.py index 77c354db..d0508774 100644 --- a/tests/test_android_portal_http_wiring.py +++ b/tests/test_android_portal_http_wiring.py @@ -102,14 +102,21 @@ def test_device_commands_route_url_to_portal_http(tmp_path, monkeypatch, no_adb) from mobilerun.cli import device_commands created = {} + def connect(self, device_id, **kwargs): created.update(kwargs) return object() 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"}): + 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" @@ -122,7 +129,9 @@ def test_device_commands_url_without_token_fails_cleanly(tmp_path, monkeypatch, from mobilerun.cli.main import cli monkeypatch.delenv("MOBILERUN_ANDROID_PORTAL_TOKEN", raising=False) - result = CliRunner().invoke(cli, ["device", "ui", "-d", PORTAL_URL, "-c", _config_file(tmp_path)]) + result = CliRunner().invoke( + cli, ["device", "ui", "-d", PORTAL_URL, "-c", _config_file(tmp_path)] + ) assert result.exit_code == 1 assert "token" in result.output @@ -134,11 +143,14 @@ def test_ping_checks_portal_http_without_adb(monkeypatch, no_adb): from mobilerun.cli.main import cli created = {} + def connect(self, device_id, **kwargs): created.update(kwargs) + class Core: def ui(self): return {} + return Core() monkeypatch.setattr(Mobilerun, "connect", connect) diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index 165e4acd..828bf6fa 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -29,15 +29,32 @@ def commands(group=cli, path=()): @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.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) @@ -49,47 +66,115 @@ def test_every_command_has_help(path, command, flag): def test_tree_and_short_options(): - assert set(cli.commands) == {"run", "device", "auth", "configure", "macro", "doctor", "env"} + assert set(cli.commands) == { + "run", + "device", + "auth", + "configure", + "macro", + "doctor", + "env", + } assert set(cli.commands["device"].commands) == { - "list", "connect", "disconnect", "setup", "ping", "tap", "swipe", "long-press", - "type", "press", "screenshot", "ui", "apps", "start", + "list", + "connect", + "disconnect", + "setup", + "ping", + "tap", + "swipe", + "long-press", + "type", + "press", + "screenshot", + "ui", + "apps", + "start", } assert set(cli.commands["auth"].commands) == {"login", "logout", "status"} - 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"} + 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("--")): + 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"], -]) +@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): +@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") @@ -99,11 +184,15 @@ def test_env_binding_and_flag_precedence(monkeypatch, tmp_path, envvar, flag, va 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) + 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) + 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 @@ -115,17 +204,30 @@ def test_auth_url_env_and_flag_precedence(monkeypatch): 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 ( + 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"): + 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"}}) + 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 @@ -139,29 +241,51 @@ def test_env_sources_and_secret_config(monkeypatch, tmp_path): 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_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)} + 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(selection, "resolve_android_serial", AsyncMock(side_effect=AssertionError("must not discover"))) + monkeypatch.setattr( + selection, + "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"]]) +@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"}, _conn=object()) - monkeypatch.setattr(mobilerun_core.Mobilerun, "list_devices", lambda *a, **kw: [core]) + core = SimpleNamespace( + device_id="id", capabilities={"backend": "local-android-adb"}, _conn=object() + ) + 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"}] @@ -185,14 +309,26 @@ def test_macro_list_json(tmp_path): def test_doctor_failure_json(monkeypatch): doctor = importlib.import_module("mobilerun.cli.doctor_command") from mobilerun.cli.doctor import CheckResult, Status - monkeypatch.setattr(doctor, "run_doctor", AsyncMock(return_value=[CheckResult("Device", Status.FAIL, "missing device")])) + + monkeypatch.setattr( + 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) == {"error": "Health checks failed"} -@pytest.mark.parametrize("service,option", [("mobilerun", "--model"), ("xai", "--callback-host"), - ("openai", "--token"), ("anthropic", "--model")]) +@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 @@ -204,14 +340,21 @@ def test_provider_table_matches_wizard_and_loaders(): for provider in PROVIDERS.values(): if provider.family: - assert resolve_provider_variant(provider.family, provider.auth_mode).id == provider.runtime + 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"}} + 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)) + monkeypatch.setattr( + device_lifecycle.adb, "connect", AsyncMock(side_effect=KeyboardInterrupt) + ) result = CliRunner().invoke(cli, ["device", "connect", "serial"]) assert result.exit_code == 130 @@ -228,7 +371,9 @@ def test_import_has_no_cli_global_side_effects(): 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) + result = subprocess.run( + [sys.executable, "-c", script], capture_output=True, text=True + ) assert result.returncode == 0, result.stderr @@ -238,25 +383,38 @@ 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(run_module, "MobileAgent", fake_agent(SimpleNamespace(success=success, reason="observed result", steps=4))) + monkeypatch.setattr( + run_module, + "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 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 @@ -265,18 +423,32 @@ 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(run_module, "MobileAgent", fake_agent(SimpleNamespace(success=True, reason="done", steps=1), calls)) + monkeypatch.setattr( + run_module, + "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"]) + 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 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 @@ -285,9 +457,15 @@ async def connect(self): 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"}})) + 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()) @@ -300,9 +478,13 @@ def test_auth_logout_preserves_other_services(tmp_path): def test_auth_status_session_error_prints_json_without_token(monkeypatch): - monkeypatch.setattr(deviceauth, "load_credentials", lambda: {"access_token": "fake-secret"}) + 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 @@ -313,18 +495,24 @@ def fail(*args): 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('{}') + 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(macro, "MacroPlayer", Player) - monkeypatch.setattr(macro, "select_device", AsyncMock(return_value=SimpleNamespace(device="serial", config=MobileConfig()))) + 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 @@ -334,28 +522,48 @@ async def replay_macro(self, *args, **kwargs): @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}) + 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 + 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) + 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 + 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"]) @@ -363,9 +571,17 @@ 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}", _conn=SimpleNamespace( - _device_cache=SimpleNamespace(name=f"device-{state}", state=state, type="android"))) - for state in ("ready", "stopped", "provisioning")] + rows = [ + SimpleNamespace( + device_id=f"id-{state}", + _conn=SimpleNamespace( + _device_cache=SimpleNamespace( + name=f"device-{state}", state=state, type="android" + ) + ), + ) + for state in ("ready", "stopped", "provisioning") + ] list_call = Mock(return_value=rows) monkeypatch.setattr(mobilerun_core.Mobilerun, "list_devices", list_call) flags = ["--all"] if scope == "all" else ["-t", "cloud"] @@ -376,22 +592,40 @@ def test_cloud_list_includes_every_state_and_metadata(monkeypatch, scope): 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} device-{state} {state} android" 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): + assert all( + f"id-{state} device-{state} {state} android" 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(run_module.ConfigLoader, "load", lambda _: config) - monkeypatch.setattr(run_module, "MobileAgent", fake_agent(SimpleNamespace(success=True, reason="done", steps=1))) + monkeypatch.setattr( + run_module, + "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()) + 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 == "" @@ -403,8 +637,14 @@ def test_run_only_providers_work_and_configure_rejects_them(monkeypatch, provide llm = object() load = Mock(return_value=llm) monkeypatch.setattr(run_module, "load_llm", load) - monkeypatch.setattr(run_module, "MobileAgent", fake_agent(SimpleNamespace(success=True, reason="done", steps=1))) - result = CliRunner().invoke(cli, ["run", "task", "-p", provider, "-m", "test-model", "-j"]) + monkeypatch.setattr( + run_module, + "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]) @@ -412,19 +652,39 @@ def test_run_only_providers_work_and_configure_rejects_them(monkeypatch, provide assert "run only" 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): +@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() @@ -449,9 +709,17 @@ def test_device_verbs_call_core_directly(monkeypatch, args, verb, expected_args, 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"]]) +@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")) @@ -467,7 +735,12 @@ def test_unsupported_core_operation_includes_alternative(monkeypatch): 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") + 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 @@ -476,10 +749,13 @@ def test_unsupported_core_operation_includes_alternative(monkeypatch): 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"]) + 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() @@ -501,17 +777,40 @@ def test_env_path_and_unset_sources_and_effective_telemetry(monkeypatch, tmp_pat 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") + 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"]]) +@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"))) + 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 @@ -520,16 +819,21 @@ def test_missing_env_config_is_an_error(monkeypatch, tmp_path, args): 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) @@ -546,22 +850,31 @@ def test_configure_api_provider_preselects_api_key_and_validates_auth_mode(monke 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 + 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 + 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): +def test_macro_replay_unexpected_error_printed_once_with_traceback( + monkeypatch, tmp_path +): macro = importlib.import_module("mobilerun.macro.cli") + 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(macro, "MacroPlayer", Player) @@ -576,6 +889,8 @@ def load_macro_from_file(self, path): 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 + assert all( + "--tcp" not in getattr(param, "opts", ()) for param in command.params + ), path for flag in ("-a", "-d", "-P", "-c"): assert runner.invoke(cli, ["device", "list", flag, "x"]).exit_code == 2 diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py index d052312b..cff4b9b6 100644 --- a/tests/test_cli_exit_codes.py +++ b/tests/test_cli_exit_codes.py @@ -28,7 +28,9 @@ def test_connect_fresh_success(monkeypatch): def test_connect_already_connected(monkeypatch): monkeypatch.setattr( - device_lifecycle.adb, "connect", AsyncMock(return_value="already connected to device") + device_lifecycle.adb, + "connect", + AsyncMock(return_value="already connected to device"), ) result = CliRunner().invoke(main.cli, ["device", "connect", "device"]) @@ -50,7 +52,9 @@ def test_connect_failure_string(monkeypatch): def test_connect_exception(monkeypatch): monkeypatch.setattr( - device_lifecycle.adb, "connect", AsyncMock(side_effect=RuntimeError("connection broke")) + device_lifecycle.adb, + "connect", + AsyncMock(side_effect=RuntimeError("connection broke")), ) result = CliRunner().invoke(main.cli, ["device", "connect", "device"]) @@ -71,7 +75,9 @@ def test_disconnect_exception(monkeypatch): def test_disconnect_not_connected_is_success(monkeypatch): - monkeypatch.setattr(device_lifecycle.adb, "disconnect", AsyncMock(return_value=False)) + monkeypatch.setattr( + device_lifecycle.adb, "disconnect", AsyncMock(return_value=False) + ) result = CliRunner().invoke(main.cli, ["device", "disconnect", "device"]) @@ -80,15 +86,25 @@ def test_disconnect_not_connected_is_success(monkeypatch): def test_ping_unknown_device(monkeypatch): - monkeypatch.setattr(device_lifecycle, "probe_device", lambda *_: (_ for _ in ()).throw(RuntimeError("unknown device"))) + 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"]) + 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 diff --git a/tests/test_cli_polish_fix2.py b/tests/test_cli_polish_fix2.py index 0b2247f7..6b52ddc4 100644 --- a/tests/test_cli_polish_fix2.py +++ b/tests/test_cli_polish_fix2.py @@ -20,20 +20,31 @@ @pytest.fixture(autouse=True) def isolated_config(monkeypatch, tmp_path): - monkeypatch.setattr(ConfigLoader, "get_user_config_path", lambda: tmp_path / "config.yaml") + 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", + "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"), -]) +@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, + monkeypatch, + platform, + name, + other_name, ): other = "ios" if platform == "android" else "android" config = DeviceConfig(platform=platform, auth_token="config-token") @@ -41,7 +52,10 @@ def test_platform_tokens_override_config_without_crossing_platforms( 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", + MobileConfig(device=config), + "http://localhost:9", + other, + f"local-{other}-http", ) if other == "ios": assert selection.portal_token(selected) is None @@ -58,9 +72,14 @@ def test_platform_tokens_override_config_without_crossing_platforms( @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 + 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 + assert ( + DeviceConfig(auth_token="config-token").resolve_auth_token(platform) is None + ) @pytest.mark.parametrize("platform", ["android", "ios"]) @@ -68,7 +87,10 @@ 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" + assert ( + DeviceConfig(platform=platform, auth_token="config-token").resolve_auth_token() + == "config-token" + ) def test_env_masks_both_platform_tokens(monkeypatch): @@ -82,16 +104,23 @@ def test_env_masks_both_platform_tokens(monkeypatch): 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"), -]) + 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")) + 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)} @@ -103,8 +132,12 @@ def test_env_uses_config_token_only_for_configured_platform(platform, name, othe @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")) +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) @@ -117,14 +150,24 @@ async def stream_events(self): 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(run_module, "MobileAgent", agent) monkeypatch.setattr(run_module, "_cleanup_android_keyboard", AsyncMock()) - result = CliRunner().invoke(cli, [ - "run", "task", "-P", "ios", "-d", "http://localhost:8080", "-j", - ]) + 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" @@ -137,15 +180,33 @@ async def done(): "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.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}, ], }, @@ -168,7 +229,9 @@ async def done(): ] -@pytest.mark.parametrize("device_id", ["serial", "123e4567-e89b-12d3-a456-426614174000"]) +@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 @@ -204,12 +267,29 @@ def test_ui_json_remains_raw_core_state(monkeypatch): 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]}, - ]}}) + 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) @@ -221,11 +301,14 @@ def test_ui_uses_description_for_whitespace_and_sanitizes_every_control(monkeypa ] -@pytest.mark.parametrize("state", [ - UiState({"a11y_tree": None, "ime_tree": [{"text": "keyboard"}]}), - SimpleNamespace(dead_tree=True), - SimpleNamespace(a11y_tree_available=False), -]) +@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 @@ -259,35 +342,91 @@ def test_ui_all_and_json_is_a_usage_error_before_connecting(monkeypatch): 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, "MOBILERUN_CLOUD_API_KEY"), - ("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", + 401, + { + "detail": "Not authorized", + "title": "Unauthorized", + "code": "unauthorized", + }, + "Not authorized", + "unauthorized", + "mobilerun auth login", + ), + ( + "status", + 403, + {"title": "Forbidden"}, + "Forbidden", + None, + "MOBILERUN_CLOUD_API_KEY", + ), + ( + "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"), + ( + "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) + 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), - }) + 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, + 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}) + 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" @@ -300,14 +439,26 @@ def respond(request, kind=kind, status=status, body=body): def invoke_api_error(monkeypatch, error, *flags): monkeypatch.setenv("MOBILERUN_CLOUD_API_KEY", ENV_KEY) - monkeypatch.setattr(device_lifecycle, "core_client", lambda *a, **kw: SimpleNamespace( - list_devices=Mock(side_effect=error), - )) + monkeypatch.setattr( + device_lifecycle, + "core_client", + lambda *a, **kw: SimpleNamespace( + list_devices=Mock(side_effect=error), + ), + ) 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", + 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: ") @@ -329,7 +480,11 @@ def test_api_errors_text(monkeypatch, sdk_error_cases): 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") - assert json.loads(result.stdout) == {"error": message, "status": status, "code": code} + assert json.loads(result.stdout) == { + "error": message, + "status": status, + "code": code, + } def test_wrapped_api_error_uses_sdk_cause(monkeypatch, sdk_error_cases): @@ -337,4 +492,8 @@ def test_wrapped_api_error_uses_sdk_cause(monkeypatch, sdk_error_cases): 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} + assert json.loads(result.stdout) == { + "error": message, + "status": status, + "code": code, + } diff --git a/tests/test_cloud_cli.py b/tests/test_cloud_cli.py index c4b0c3ed..e3e6a3cd 100644 --- a/tests/test_cloud_cli.py +++ b/tests/test_cloud_cli.py @@ -62,7 +62,12 @@ def test_cloud_uuid_requires_credential_without_falling_back_to_adb(monkeypatch) monkeypatch.setattr(selection.ConfigLoader, "load", lambda _: MobileConfig()) 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"}): + with device_commands.device_driver( + { + "device": "123e4567-e89b-12d3-a456-426614174000", + "api_url": "https://example.test/v1", + } + ): pass @@ -80,9 +85,11 @@ def test_cloud_facade_receives_url_key_and_id(monkeypatch): from mobilerun.config_manager import MobileConfig calls = [] + class FakeCore: def __init__(self, **kwargs): calls.append(kwargs) + def connect(self, device_id, **kwargs): calls.append({"device_id": device_id, **kwargs}) return object() @@ -91,7 +98,9 @@ def connect(self, device_id, **kwargs): monkeypatch.setattr(selection.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"}): + 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 @@ -119,7 +128,13 @@ def test_local_listing_uses_local_scope(monkeypatch): class FakeCore: def list_devices(self, **kwargs): assert kwargs["scope"] == "local" - return [SimpleNamespace(device_id="serial", capabilities={"backend": "local-android-adb"}, _conn=object())] + return [ + SimpleNamespace( + device_id="serial", + capabilities={"backend": "local-android-adb"}, + _conn=object(), + ) + ] monkeypatch.setattr(device_lifecycle, "core_client", lambda *_, **kw: FakeCore()) result = CliRunner().invoke(cli, ["device", "list", "-j"]) diff --git a/tests/test_core_local_runtime.py b/tests/test_core_local_runtime.py index c9bb30e8..69796540 100644 --- a/tests/test_core_local_runtime.py +++ b/tests/test_core_local_runtime.py @@ -47,11 +47,15 @@ async def connect(self): ), ) from mobilerun_core_local.driver import android + 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") + return await selection._native_driver( + selected, selected.config, "https://example.test/v1" + ) + driver = asyncio.run(connect()) assert isinstance(driver, FakeAndroidDriver) @@ -91,14 +95,16 @@ async def fake_ensure_portal_ready(*args, **kwargs): ), ) from mobilerun_core_local.driver import android + monkeypatch.setattr(android, "AndroidDriver", FakeAndroidDriver) - monkeypatch.setattr( - android.portal, "ensure_portal_ready", fake_ensure_portal_ready - ) + 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") + return await selection._native_driver( + selected, selected.config, "https://example.test/v1" + ) + asyncio.run(connect()) assert setup_calls == [] diff --git a/tests/test_gemini_oauth_llm.py b/tests/test_gemini_oauth_llm.py index c03b0479..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.*auth login gemini"): + 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 8f321f2c..ec6da461 100644 --- a/tests/test_grok_cli.py +++ b/tests/test_grok_cli.py @@ -79,7 +79,8 @@ def test_configure_xai_command_forwards_device_code_options( result = CliRunner().invoke( cli_main.cli, [ - "auth", "login", + "auth", + "login", "xai", "--credential-path", str(credential_path), @@ -118,7 +119,8 @@ def test_configure_anthropic_forwards_timeout_and_browser_preference( result = CliRunner().invoke( cli_main.cli, [ - "auth", "login", + "auth", + "login", "anthropic", "--credential-path", str(credential_path), diff --git a/tests/test_ios_local_portal_wiring.py b/tests/test_ios_local_portal_wiring.py index 0cd9747e..8aeb8e3e 100644 --- a/tests/test_ios_local_portal_wiring.py +++ b/tests/test_ios_local_portal_wiring.py @@ -18,14 +18,20 @@ def test_auth_token_reads_config_value(monkeypatch): from mobilerun.config_manager.config_manager import DeviceConfig monkeypatch.delenv("MOBILERUN_IOS_PORTAL_TOKEN", raising=False) - assert DeviceConfig(platform="ios", auth_token="from-config").resolve_auth_token() == "from-config" + 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_IOS_PORTAL_TOKEN", "from-env") - assert DeviceConfig(platform="ios", auth_token="from-config").resolve_auth_token() == "from-env" + assert ( + DeviceConfig(platform="ios", auth_token="from-config").resolve_auth_token() + == "from-env" + ) LOCAL_PORTAL_STATE = { @@ -115,11 +121,15 @@ def test_device_commands_ui_returns_core_state(monkeypatch): from mobilerun_core import Mobilerun from mobilerun.cli.main import cli + class Device: 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"]) + result = CliRunner().invoke( + cli, ["device", "ui", "-d", "http://127.0.0.1:8080", "-P", "ios", "-j"] + ) assert result.exit_code == 0, result.output assert json.loads(result.stdout) == LOCAL_PORTAL_STATE @@ -130,15 +140,23 @@ def test_device_commands_route_ios_through_facade(tmp_path, monkeypatch): from mobilerun.cli import device_commands created = {} + def connect(self, device_id, **kwargs): created.update(kwargs) return object() + 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") - with device_commands.device_driver({"device": "http://127.0.0.1:8080", "config_path": str(cfg), - "platform": "ios", "api_url": "https://example.test/v1"}): + 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" @@ -151,16 +169,25 @@ def test_device_commands_discover_when_no_device(tmp_path, monkeypatch): from mobilerun.cli import device_commands, selection created = {} + async def discover(): return "http://127.0.0.1:8081" + def connect(self, device_id, **kwargs): created.update(kwargs) return object() + monkeypatch.setattr(selection, "discover_ios_device", discover) monkeypatch.setattr(Mobilerun, "connect", connect) cfg = tmp_path / "config.yaml" cfg.write_text("_version: 7\ndevice:\n platform: ios\n") - with device_commands.device_driver({"config_path": str(cfg), "platform": "ios", "api_url": "https://example.test/v1"}): + 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_state_fetch_retry_policy.py b/tests/test_state_fetch_retry_policy.py index ecf9be8b..4b72b207 100644 --- a/tests/test_state_fetch_retry_policy.py +++ b/tests/test_state_fetch_retry_policy.py @@ -222,7 +222,9 @@ def test_device_ui_shows_info_progress_on_stderr(monkeypatch): class Device: def ui_json(self): - logging.getLogger("mobilerun").info("Waiting for the UI state from the device...") + logging.getLogger("mobilerun").info( + "Waiting for the UI state from the device..." + ) return '{"a11y_tree": {}}' monkeypatch.setattr(Mobilerun, "connect", lambda *a, **kw: Device()) diff --git a/tests/test_vision_only_run_cli.py b/tests/test_vision_only_run_cli.py index 3bfbf916..a0e2e694 100644 --- a/tests/test_vision_only_run_cli.py +++ b/tests/test_vision_only_run_cli.py @@ -21,7 +21,8 @@ def test_vision_only_forwards_normal_run_options(self): "run", "Check Wi-Fi", "--vision-only", - "--platform", "ios", + "--platform", + "ios", "--backend", "visual-remote", "--device", @@ -106,7 +107,9 @@ def run(self): return_value=MobileConfig(), ), patch("mobilerun.cli.run.MobileAgent", FakeAgent), - patch("mobilerun.cli.selection.discover_ios_device", AsyncMock()) as discover, + patch( + "mobilerun.cli.selection.discover_ios_device", AsyncMock() + ) as discover, ): success = asyncio.run( run_command( From 0a6ebcf6cb0e9082d766f1daf79c8be1e3e52cf7 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Fri, 13 Nov 2026 00:25:44 +0100 Subject: [PATCH 55/59] style(cli): apply black --- mobilerun/cli/device_pair.py | 92 ++++++++++---- tests/test_cli_qr.py | 230 ++++++++++++++++++++++++++--------- 2 files changed, 236 insertions(+), 86 deletions(-) diff --git a/mobilerun/cli/device_pair.py b/mobilerun/cli/device_pair.py index 5eb23ce1..1314a03f 100644 --- a/mobilerun/cli/device_pair.py +++ b/mobilerun/cli/device_pair.py @@ -61,19 +61,29 @@ def _run_adb( ) -> 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, + ["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_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()) + 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): @@ -83,7 +93,9 @@ def _adb(*args: str, deadline: float, input: str | None = None): 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 + 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") @@ -105,14 +117,23 @@ def _wait_for_service(service_type: str, deadline: float, *, name: str) -> MdnsS 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) + 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"] + 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: @@ -130,15 +151,26 @@ def _wait_for_transport(guid: str, deadline: float) -> str: 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) + 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)): + 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) + 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) @@ -153,7 +185,10 @@ def _wait_for_transport(guid: str, deadline: float) -> str: @device_cli.command() @verbose_option @click.option( - "--timeout", type=click.IntRange(min=1), default=120, show_default=True, + "--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): @@ -161,28 +196,31 @@ def pair(timeout: int, verbose: bool | None): if not sys.stderr.isatty(): raise click.UsageError("This command needs a terminal.") if not shutil.which("adb"): - raise click.ClickException("adb is missing; install Android SDK Platform-Tools.") + raise click.ClickException( + "adb is missing; install Android SDK Platform-Tools." + ) deadline = time.monotonic() + timeout 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.") + 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)) + password = "".join( + secrets.choice(string.ascii_letters + string.digits) for _ in range(10) + ) click.echo( - 'On the device, open Developer options → Wireless debugging → ' + "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) + 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) diff --git a/tests/test_cli_qr.py b/tests/test_cli_qr.py index 633b8d9f..d1fffdfa 100644 --- a/tests/test_cli_qr.py +++ b/tests/test_cli_qr.py @@ -25,7 +25,9 @@ def isolation(self, *args, **kwargs): @pytest.fixture(autouse=True) def isolated_config(monkeypatch, tmp_path): - monkeypatch.setattr(ConfigLoader, "get_user_config_path", lambda: tmp_path / "config.yaml") + 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) @@ -49,12 +51,16 @@ def test_print_qr_uses_stderr_and_preserves_quiet_zone(monkeypatch): 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) + 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")]) +@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 @@ -64,7 +70,9 @@ def test_print_qr_encoding_failure_returns_false(monkeypatch, error): assert output.getvalue() == "" -@pytest.mark.parametrize("error", [OSError("cannot write"), UnicodeError("unsupported encoding")]) +@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 @@ -78,8 +86,9 @@ def login(monkeypatch, tmp_path): import webbrowser link = "https://example.test/verify?user_code=ABCD-EFGH" - code = deviceauth.CodeResponse("device-code", "ABCD-EFGH", "https://example.test/verify", - link, 120, 5) + 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") @@ -91,8 +100,14 @@ def login(monkeypatch, tmp_path): 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) + 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): @@ -100,11 +115,19 @@ def test_login_prints_complete_link_qr_and_preserves_flow(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") + login.printed.assert_called_once_with( + "https://example.test/verify?user_code=ABCD-EFGH" + ) assert "\x1b[97;40m" in result.stderr - login.browser.assert_called_once_with("https://example.test/verify?user_code=ABCD-EFGH") - 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.browser.assert_called_once_with( + "https://example.test/verify?user_code=ABCD-EFGH" + ) + 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) @@ -113,8 +136,12 @@ def test_login_no_qr_preserves_browser_and_polling(login): assert result.exit_code == 0, result.output assert result.stdout == "" login.printed.assert_not_called() - login.browser.assert_called_once_with("https://example.test/verify?user_code=ABCD-EFGH") - login.poll.assert_called_once_with(auth.DEFAULT_AUTH_URL, auth.DEFAULT_DEVICE_CLIENT_ID, login.code) + login.browser.assert_called_once_with( + "https://example.test/verify?user_code=ABCD-EFGH" + ) + 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): @@ -124,17 +151,25 @@ def test_login_without_complete_link_skips_qr_and_browser(login): 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) + 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(qr.segno, "make_qr", Mock(side_effect=ValueError("cannot encode"))) + monkeypatch.setattr( + qr.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_called_once_with("https://example.test/verify?user_code=ABCD-EFGH") - login.poll.assert_called_once_with(auth.DEFAULT_AUTH_URL, auth.DEFAULT_DEVICE_CLIENT_ID, login.code) + login.browser.assert_called_once_with( + "https://example.test/verify?user_code=ABCD-EFGH" + ) + 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) @@ -166,14 +201,25 @@ def test_mdns_parser_reads_services_and_ignores_header(): ] -@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", -]) +@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}" - ) == [] + assert ( + device_pair.parse_mdns_services( + f"mobilerun-a1b2c3\t_adb-tls-pairing._tcp\t{address}" + ) + == [] + ) @pytest.fixture @@ -182,17 +228,30 @@ 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) - )) + 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, + "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, + "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", ""), @@ -206,15 +265,21 @@ def run(*args, **kwargs): 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) + 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" + ( + 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", ""), + "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", ""), @@ -230,8 +295,10 @@ def test_adb_devices_parser_ignores_header_and_retains_states(): "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", + "adb-device._adb-tls-connect._tcp": "device", + "192.0.2.2:5555": "offline", + "usb-device": "unauthorized", + "other-device": "connecting", } @@ -241,23 +308,35 @@ def test_pair_waits_for_auto_connect_without_exposing_password(pairing): 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 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",), + ("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 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}", "") + 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" @@ -293,7 +372,9 @@ def test_pair_fallback_rechecks_auto_connect_before_connecting(fallback, state): 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, ""), + (0, ipv6, ""), + (0, "List of devices attached\n", ""), + (0, ipv6, ""), ] result = TerminalRunner().invoke(cli, ["device", "pair", "--timeout", "18"]) assert result.exit_code == 1 @@ -304,8 +385,12 @@ def test_pair_fallback_needs_ipv4_and_keeps_overall_deadline(fallback): 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[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 @@ -332,7 +417,11 @@ def test_pair_cleans_up_late_auto_connect_after_fallback(fallback): def test_pair_requires_guid_in_success_reply(pairing): - pairing.responses[3] = (0, "Enter pairing code: Successfully paired to 192.0.2.1:32001", "") + 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 @@ -341,9 +430,12 @@ def test_pair_requires_guid_in_success_reply(pairing): def test_pair_prefers_ipv4_pairing_when_ipv6_is_listed_first(pairing): - pairing.responses[2] = (0, + 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", "") + "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" @@ -351,8 +443,11 @@ def test_pair_prefers_ipv4_pairing_when_ipv6_is_listed_first(pairing): 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", "") + 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" @@ -384,7 +479,9 @@ def test_device_connect_reply(monkeypatch, reply, exit_code): def test_pair_fails_if_qr_cannot_be_rendered(monkeypatch, pairing): monkeypatch.setattr(device_pair, "print_qr", qr.print_qr) - monkeypatch.setattr(qr.segno, "make_qr", Mock(side_effect=ValueError("cannot encode"))) + monkeypatch.setattr( + qr.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 @@ -402,12 +499,23 @@ def test_pair_timeout(pairing, response_count, limit): 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( + "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): +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"]) @@ -477,6 +585,10 @@ def test_adb_wrapper_passes_password_only_on_stdin(monkeypatch): 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, + ["adb", "pair", "192.0.2.1:32001"], + input="test-password\n", + capture_output=True, + text=True, + timeout=3, + check=False, ) From d6f8125d904243cebd1ac720dfaae916894d6540 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Fri, 13 Nov 2026 00:28:07 +0100 Subject: [PATCH 56/59] style(cli): apply black --- mobilerun/__init__.py | 46 ++--- mobilerun/agent/__init__.py | 2 +- mobilerun/agent/droid/__init__.py | 9 +- mobilerun/agent/utils/__init__.py | 17 +- mobilerun/cli/tree.py | 17 +- mobilerun/config_manager/__init__.py | 39 ++-- tests/test_cli_devx.py | 275 ++++++++++++++++++++------- tests/test_cli_exit_codes.py | 5 +- tests/test_cli_runtime_policy.py | 214 +++++++++++++++------ tests/test_cli_startup.py | 26 ++- tests/test_core_local_runtime.py | 1 - 11 files changed, 458 insertions(+), 193 deletions(-) diff --git a/mobilerun/__init__.py b/mobilerun/__init__.py index f1981ac4..f2de2a27 100644 --- a/mobilerun/__init__.py +++ b/mobilerun/__init__.py @@ -52,29 +52,29 @@ from mobilerun.tools import AndroidDriver, DeviceDriver, RecordingDriver _EXPORTS = { - 'ResultEvent': ('mobilerun.agent', 'ResultEvent'), - 'MobileAgent': ('mobilerun.agent.droid', 'MobileAgent'), - 'MobileAgentState': ('mobilerun.agent.droid', 'MobileAgentState'), - 'load_llm': ('mobilerun.agent.utils.llm_picker', 'load_llm'), - 'AgentConfig': ('mobilerun.config_manager', 'AgentConfig'), - 'AppCardConfig': ('mobilerun.config_manager', 'AppCardConfig'), - 'CredentialsConfig': ('mobilerun.config_manager', 'CredentialsConfig'), - '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'), + "ResultEvent": ("mobilerun.agent", "ResultEvent"), + "MobileAgent": ("mobilerun.agent.droid", "MobileAgent"), + "MobileAgentState": ("mobilerun.agent.droid", "MobileAgentState"), + "load_llm": ("mobilerun.agent.utils.llm_picker", "load_llm"), + "AgentConfig": ("mobilerun.config_manager", "AgentConfig"), + "AppCardConfig": ("mobilerun.config_manager", "AppCardConfig"), + "CredentialsConfig": ("mobilerun.config_manager", "CredentialsConfig"), + "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 diff --git a/mobilerun/agent/__init__.py b/mobilerun/agent/__init__.py index eff36c0d..cf0dc2bd 100644 --- a/mobilerun/agent/__init__.py +++ b/mobilerun/agent/__init__.py @@ -5,7 +5,7 @@ from mobilerun.agent.droid.events import ResultEvent _EXPORTS = { - 'ResultEvent': ('mobilerun.agent.droid.events', 'ResultEvent'), + "ResultEvent": ("mobilerun.agent.droid.events", "ResultEvent"), } __all__ = ["ResultEvent"] diff --git a/mobilerun/agent/droid/__init__.py b/mobilerun/agent/droid/__init__.py index 52bc4aa7..0cfea8a3 100644 --- a/mobilerun/agent/droid/__init__.py +++ b/mobilerun/agent/droid/__init__.py @@ -1,6 +1,7 @@ """Mobilerun Agent Module. -This module provides a ReAct agent for automating Android devices using reasoning and acting.""" +This module provides a ReAct agent for automating Android devices using reasoning and acting. +""" from importlib import import_module from typing import TYPE_CHECKING @@ -10,15 +11,15 @@ from mobilerun.agent.droid.state import MobileAgentState _EXPORTS = { - 'MobileAgent': ('mobilerun.agent.droid.droid_agent', 'MobileAgent'), - 'MobileAgentState': ('mobilerun.agent.droid.state', 'MobileAgentState'), + "MobileAgent": ("mobilerun.agent.droid.droid_agent", "MobileAgent"), + "MobileAgentState": ("mobilerun.agent.droid.state", "MobileAgentState"), } __all__ = ["MobileAgent", "MobileAgentState", "DroidAgent", "DroidAgentState"] def __getattr__(name): - aliases = {'DroidAgent': 'MobileAgent', 'DroidAgentState': 'MobileAgentState'} + aliases = {"DroidAgent": "MobileAgent", "DroidAgentState": "MobileAgentState"} if name in aliases: import warnings diff --git a/mobilerun/agent/utils/__init__.py b/mobilerun/agent/utils/__init__.py index c34f7c0d..6965e4b7 100644 --- a/mobilerun/agent/utils/__init__.py +++ b/mobilerun/agent/utils/__init__.py @@ -15,13 +15,16 @@ 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'), + "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__ = [ diff --git a/mobilerun/cli/tree.py b/mobilerun/cli/tree.py index 2834140f..ef1b79b5 100644 --- a/mobilerun/cli/tree.py +++ b/mobilerun/cli/tree.py @@ -14,12 +14,21 @@ @click.group( cls=Group, - command_order=["run", "device", "configure", "auth", "doctor", "env", "macro", "completion"], + command_order=[ + "run", + "device", + "configure", + "auth", + "doctor", + "env", + "macro", + "completion", + ], epilog=examples( - 'mobilerun device list', - 'mobilerun configure -p openai', + "mobilerun device list", + "mobilerun configure -p openai", 'mobilerun run "Open Settings"', - docs='https://docs.mobilerun.ai/framework/guides/cli', + docs="https://docs.mobilerun.ai/framework/guides/cli", ), ) @click.version_option(version("mobilerun"), "-V", "--version") diff --git a/mobilerun/config_manager/__init__.py b/mobilerun/config_manager/__init__.py index bae3271b..fe31c0fb 100644 --- a/mobilerun/config_manager/__init__.py +++ b/mobilerun/config_manager/__init__.py @@ -23,24 +23,27 @@ 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'), - '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'), + "AgentConfig": ("mobilerun.config_manager.config_manager", "AgentConfig"), + "AppCardConfig": ("mobilerun.config_manager.config_manager", "AppCardConfig"), + "CredentialsConfig": ( + "mobilerun.config_manager.config_manager", + "CredentialsConfig", + ), + "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__ = [ diff --git a/tests/test_cli_devx.py b/tests/test_cli_devx.py index 6c88e977..d44de58c 100644 --- a/tests/test_cli_devx.py +++ b/tests/test_cli_devx.py @@ -45,16 +45,25 @@ def commands(command=cli, path=()): @pytest.fixture(autouse=True) def isolated_config(monkeypatch, tmp_path): - monkeypatch.setattr(ConfigLoader, "get_user_config_path", lambda: tmp_path / "config.yaml") + 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"): + 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) + monkeypatch.setattr( + configure, "input_allowed", lambda disabled: tty and not disabled + ) patch() return patch @@ -64,13 +73,18 @@ def patch(tty=True): def secret_command(request, monkeypatch, tmp_path): callback = Mock() if request.param == "configure": - monkeypatch.setattr("mobilerun.cli.configure_wizard.run_configure_wizard", callback) + 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) + monkeypatch.setattr( + "mobilerun.cli.oauth_actions.save_anthropic_setup_token", callback + ) args = [ "auth", "login", @@ -121,10 +135,14 @@ def test_help_options_and_summaries_are_complete(path, command): 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 + 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 + 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 @@ -134,11 +152,22 @@ def test_help_options_and_summaries_are_complete(path, command): def test_root_command_order_and_agent_discovery(monkeypatch): monkeypatch.setattr( - run_module, "list_agents", Mock(side_effect=AssertionError("root must not scan")) + 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"] + 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 ) @@ -153,7 +182,10 @@ 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 + assert ( + "No external agents are installed; install one before using --agent." + in result.stderr + ) def test_installed_agent_is_listed_and_accepted(monkeypatch): @@ -226,7 +258,11 @@ 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")] + 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 [])]) @@ -258,7 +294,9 @@ async def check(*args, **kwargs): ) def test_local_error_next_step(monkeypatch, message, hint): - monkeypatch.setattr(device_lifecycle, "probe_device", Mock(side_effect=RuntimeError(message))) + 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 @@ -276,7 +314,8 @@ def isatty(self): output = Output() with click.Context(cli, obj={"stdout": output}): emit_rows( - ("NAME", "DETAIL"), [("first", "line\nbreak\tcolumn\x1b\u2028\u200b"), ("last", None)] + ("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 @@ -318,15 +357,21 @@ def isatty(self): ] ) def all_devices_case(request, monkeypatch): - local_failure, cloud_failure, code, ids, errors, warnings, close_counts = request.param + 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() + device_id=scope, + name=None, + state=None, + device_type=None, + capabilities={"backend": scope}, + close=Mock(), ) devices.append(device) @@ -379,7 +424,9 @@ def factory(url, *, cloud): 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): +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 @@ -390,7 +437,9 @@ def test_all_devices_text_with_no_key_and_empty_local_is_success(empty_local_wit ) -def test_all_devices_json_with_no_key_and_empty_local_is_success(empty_local_without_cloud_key): +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 @@ -401,14 +450,18 @@ def test_all_devices_json_with_no_key_and_empty_local_is_success(empty_local_wit def test_connection_hint_preserves_port_and_hides_url_secrets(): - request = httpx.Request("GET", "http://user:password@[::1]:9123/v1/devices?token=hidden") + 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"]) +@pytest.mark.parametrize( + "app_field", ["package_name", "app_id", "bundle_id", "package"] +) def test_apps_are_tsv(monkeypatch, app_field): @contextmanager @@ -449,7 +502,9 @@ def test_macro_list_is_tsv(tmp_path): assert result.stdout == "sample\tExample macro\t3\n" -def test_env_text_uses_empty_values_lowercase_booleans_and_one_secret_status(monkeypatch): +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) @@ -492,11 +547,15 @@ def test_configure_without_input_fails_before_loading_or_writing( 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")) + 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 [])]) + 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() @@ -526,7 +585,9 @@ def test_empty_secret_stdin_is_usage_error(secret_command, stdin): @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): +def test_secrets_are_redacted_from_errors_and_verbose_logs( + secret_command, from_stdin, verbose +): args, callback, _ = secret_command secret = "dummy-error-marker" @@ -537,7 +598,12 @@ def fail(*args, **kwargs): callback.side_effect = fail result = CliRunner().invoke( cli, - [*args, "-" if from_stdin else secret, "--no-input", *(["-v"] if verbose else [])], + [ + *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 @@ -556,9 +622,14 @@ def fail(*args, **kwargs): ("gemini", 2, 0, ("terminal", "--no-input")), ], ) -def test_login_only_blocks_flows_that_need_terminal_input(monkeypatch, service, code, calls, hints): +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"}) + monkeypatch.setattr( + "mobilerun.cli.oauth_actions.login_requires_input", + lambda name: name in {"anthropic", "gemini"}, + ) callbacks = [] for name in ( "_login_mobilerun", @@ -580,7 +651,9 @@ def test_login_only_blocks_flows_that_need_terminal_input(monkeypatch, service, @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) + 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"]) @@ -589,12 +662,19 @@ def test_provider_callback_login_without_manual_prompt_is_allowed(monkeypatch, s @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)) +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" + assert ( + result.stderr + == "Error: Input closed; run the command again in a terminal.\n" + ) def test_click_prompt_interrupt_exits_130(monkeypatch, allow_input): @@ -614,14 +694,18 @@ 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"]) + 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) + 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( @@ -634,11 +718,15 @@ def test_no_input_configure_never_starts_missing_oauth_login(monkeypatch): 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") + 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 ( + result.stderr.count("Input closed; run the command again in a terminal.") == 1 + ) assert "Aborted!" not in result.output @@ -670,7 +758,9 @@ def test_completion_is_click_source_without_installing(shell, tmp_path, monkeypa 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() + expected = get_completion_class(shell)( + cli, {}, "mobilerun", "_MOBILERUN_COMPLETE" + ).source() assert result.stdout == expected + "\n" assert "_MOBILERUN_COMPLETE" in result.stdout assert result.stderr == "" @@ -747,7 +837,9 @@ def test_terminal_secret_input_is_hidden(monkeypatch, secret_command): 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) + click, + "get_text_stream", + lambda name: stream if name == "stdin" else original_stream(name), ) original_prompt = click.prompt @@ -780,7 +872,9 @@ def test_cloud_empty_list_has_cloud_hint(monkeypatch): def test_cloud_missing_device_has_cloud_listing_hint(monkeypatch): monkeypatch.setattr( - device_lifecycle, "probe_device", Mock(side_effect=RuntimeError("device not found")) + 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 @@ -792,7 +886,9 @@ def test_all_devices_api_error_is_short_and_redacted(monkeypatch): 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"} + "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"]) @@ -819,7 +915,9 @@ def test_env_json_booleans_match_text(monkeypatch): @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)) + monkeypatch.setattr( + click, "get_text_stream", lambda name: SimpleNamespace(isatty=lambda: True) + ) original_prompt = click.prompt def prompt(*args, **kwargs): @@ -853,11 +951,15 @@ def test_doctor_runner_suppresses_results_and_keeps_instructions(monkeypatch, fl ), ) monkeypatch.setattr( - module, "check_adb", AsyncMock(return_value=CheckResult("ADB", Status.PASS, "ready")) + 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") + 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 @@ -871,7 +973,10 @@ def test_doctor_runner_suppresses_results_and_keeps_instructions(monkeypatch, fl [ (["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'?"), + ( + ["connect", "X"], + "No such command 'connect'. Did you mean 'mobilerun device connect'?", + ), ( ["disconnect", "X"], "No such command 'disconnect'. Did you mean 'mobilerun device disconnect'?", @@ -879,14 +984,29 @@ def test_doctor_runner_suppresses_results_and_keeps_instructions(monkeypatch, fl (["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'?"), + ( + ["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'?"), + ( + ["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'?", @@ -984,7 +1104,9 @@ def cloud_env_var(request): ] ) def cloud_credential_case(request, monkeypatch, cloud_env_var): - env, saved, key, source, has_login, label, env_source, overrides, hint = request.param + 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) @@ -1041,14 +1163,14 @@ 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" + 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]) @@ -1056,19 +1178,19 @@ 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) + 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" - ) + assert result.stderr == f"Error: Rejected [redacted] (HTTP {status})\n{hint}\n" @pytest.mark.parametrize( @@ -1114,12 +1236,18 @@ def test_cloud_credential_resolver_preserves_precedence(cloud_credential_case): 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"}}) + deviceauth, + "fetch_session", + Mock(return_value={"user": {"email": "user@example.test"}}), + ) + monkeypatch.setattr( + "mobilerun.cli.configure_wizard._oauth_credentials_present", lambda *a: False ) - 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): +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] == { @@ -1148,7 +1276,9 @@ 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" + row + for row in json.loads(result.stdout) + if row["variable"] == "MOBILERUN_API_KEY" ) assert row == { "variable": "MOBILERUN_API_KEY", @@ -1169,10 +1299,15 @@ def test_env_text_uses_cloud_credential_source(cloud_credential_case): @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)) + 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={}) + 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.") diff --git a/tests/test_cli_exit_codes.py b/tests/test_cli_exit_codes.py index 2e606f91..5e027df9 100644 --- a/tests/test_cli_exit_codes.py +++ b/tests/test_cli_exit_codes.py @@ -1,4 +1,3 @@ - import importlib from unittest.mock import AsyncMock @@ -14,7 +13,9 @@ @pytest.fixture(autouse=True) def config(monkeypatch): - monkeypatch.setattr(mobilerun.config_manager.ConfigLoader, "load", lambda _: MobileConfig()) + monkeypatch.setattr( + mobilerun.config_manager.ConfigLoader, "load", lambda _: MobileConfig() + ) def test_connect_fresh_success(monkeypatch): diff --git a/tests/test_cli_runtime_policy.py b/tests/test_cli_runtime_policy.py index 75b9afbd..4b3be869 100644 --- a/tests/test_cli_runtime_policy.py +++ b/tests/test_cli_runtime_policy.py @@ -25,34 +25,58 @@ @pytest.fixture(autouse=True) def isolated(monkeypatch, tmp_path): - monkeypatch.setattr(ConfigLoader, "get_user_config_path", lambda: tmp_path / "config.yaml") + 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"): + 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"], -]) +@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.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)) + 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" @@ -69,7 +93,9 @@ def test_local_adb_server_can_start_on_first_use(monkeypatch, tmp_path): # 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=lambda **kwargs: asyncio.run(listing()), close=Mock()) + client = SimpleNamespace( + list_devices=lambda **kwargs: asyncio.run(listing()), 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 @@ -77,14 +103,28 @@ def test_local_adb_server_can_start_on_first_use(monkeypatch, tmp_path): 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(), - )) + 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) @@ -93,8 +133,13 @@ def test_all_keeps_cloud_when_adb_fails(monkeypatch): assert result.stderr == "Warning: local: " + options.ADB_HINT + "\n" -@pytest.mark.parametrize("path", [["configure", "-p", "openai", "-m", "M", "-k", "-"], - ["auth", "login", "anthropic", "--token", "-"]]) +@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 @@ -115,10 +160,19 @@ def interrupt(*a, **k): assert "Aborted!" not in result.output -@pytest.mark.parametrize("env,flag,expected", [(None, False, True), ("false", False, False), - ("false", True, True), ("true", False, True)]) +@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") + ConfigLoader.get_user_config_path().write_text( + "_version: 7\nlogging:\n debug: true\n" + ) if env is not None: monkeypatch.setenv("MOBILERUN_DEBUG", env) @@ -134,14 +188,21 @@ def probe(params): 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"]), -]) +@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 @@ -154,7 +215,7 @@ 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 + hook.write_text("""import builtins, sys, time original = builtins.__import__ def blocked_import(name, *args, **kwargs): if name == %r: @@ -163,12 +224,19 @@ def blocked_import(name, *args, **kwargs): time.sleep(60) return original(name, *args, **kwargs) builtins.__import__ = blocked_import -''' % blocked) +""" % 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) + 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 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) @@ -183,7 +251,10 @@ def blocked_import(name, *args, **kwargs): 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 ( + "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) @@ -193,7 +264,9 @@ def test_status_no_credential_and_logout_env(monkeypatch): def test_short_token_does_not_redact_words(): - result = CliRunner().invoke(cli, ["auth", "login", "xai", "--device-code", "--token", "x"]) + 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 @@ -201,13 +274,22 @@ def test_short_token_does_not_redact_words(): 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"]) + 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 + 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"]]) +@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 @@ -224,25 +306,41 @@ 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"] == "No adb exe could be found. Install adb on your system" + 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"] + == "No adb exe could be found. Install adb on your system" + ) 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"))) + 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"], -]) +@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 diff --git a/tests/test_cli_startup.py b/tests/test_cli_startup.py index 700db9a1..2408d192 100644 --- a/tests/test_cli_startup.py +++ b/tests/test_cli_startup.py @@ -36,7 +36,12 @@ "LLMProfile", ], "mobilerun.agent": ["ResultEvent"], - "mobilerun.agent.droid": ["MobileAgent", "MobileAgentState", "DroidAgent", "DroidAgentState"], + "mobilerun.agent.droid": [ + "MobileAgent", + "MobileAgentState", + "DroidAgent", + "DroidAgentState", + ], "mobilerun.agent.utils": [ "to_chat_messages", "has_content", @@ -67,7 +72,13 @@ "PromptLoader", ], } -BANNED = ("llama_index", "mobilerun.agent.droid", "mobilerun_sdk", "mobilerun_core", "segno") +BANNED = ( + "llama_index", + "mobilerun.agent.droid", + "mobilerun_sdk", + "mobilerun_core", + "segno", +) IMPORTS = ( "from mobilerun import MobileAgent", "from mobilerun import MobileConfig", @@ -104,7 +115,8 @@ def fresh(code, *args, env=None): @pytest.mark.parametrize( - "module,name", [(mod, name) for mod, names in PUBLIC_EXPORTS.items() for name in names] + "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") @@ -171,7 +183,9 @@ def test_shell_completion_fast_import_boundary(): ) -@pytest.mark.parametrize("module", ["mobilerun", "droidrun", "mobilerun.macro", "droidrun.macro"]) +@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__')" @@ -179,4 +193,6 @@ def test_module_entrypoint_help(module): def test_compat_cli_entrypoint(): - fresh("import sys; from droidrun.cli_shim import main; sys.argv=['droidrun','--help']; main()") + fresh( + "import sys; from droidrun.cli_shim import main; sys.argv=['droidrun','--help']; main()" + ) diff --git a/tests/test_core_local_runtime.py b/tests/test_core_local_runtime.py index 10cb0a34..a1e8a5ff 100644 --- a/tests/test_core_local_runtime.py +++ b/tests/test_core_local_runtime.py @@ -1,4 +1,3 @@ - import asyncio from types import SimpleNamespace From 532660b1d82960b5f555fe594a5dac89f61dd69b Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Fri, 13 Nov 2026 22:26:48 +0100 Subject: [PATCH 57/59] fix(cli): review fixes typesafe port --- mobilerun/cli/options.py | 2 +- mobilerun/cli/providers.py | 23 +++++++-- mobilerun/cli/run.py | 85 +++++++++++++------------------ tests/test_agent_model_loading.py | 47 ++++++++--------- tests/test_delegation_config.py | 12 ++--- tests/test_typesafe_cli.py | 81 ++++++++++++++++++++++++++++- 6 files changed, 161 insertions(+), 89 deletions(-) diff --git a/mobilerun/cli/options.py b/mobilerun/cli/options.py index 869cc686..f72ecc69 100644 --- a/mobilerun/cli/options.py +++ b/mobilerun/cli/options.py @@ -221,7 +221,7 @@ def __init__(self, *args, **kwargs): 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 (optional for TypeSafe).") +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.") diff --git a/mobilerun/cli/providers.py b/mobilerun/cli/providers.py index 64056366..89e61dc3 100644 --- a/mobilerun/cli/providers.py +++ b/mobilerun/cli/providers.py @@ -38,11 +38,24 @@ def runtime(self): "openrouter": Provider(None, "api_key", "api_base", "OpenRouter"), "deepseek": Provider(None, "api_key", "base_url", "DeepSeek"), } -provider_option = click.option( - "-p", - "--provider", - type=click.Choice(list(PROVIDERS), case_sensitive=False), - help="LLM provider.", + + +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)) +run_provider_option = _provider_option( + [*PROVIDERS, "TypeSafe"], note="TypeSafe for the Jev decision model" ) diff --git a/mobilerun/cli/run.py b/mobilerun/cli/run.py index 0c443520..f9b9ab1a 100644 --- a/mobilerun/cli/run.py +++ b/mobilerun/cli/run.py @@ -30,10 +30,9 @@ json_option, llm_url_option, max_steps_option, - model_option, tcp_option, ) -from mobilerun.cli.providers import PROVIDERS, llm_url_kwargs +from mobilerun.cli.providers import PROVIDERS, llm_url_kwargs, run_provider_option from mobilerun.cli.selection import _native_driver, select_device from mobilerun.config_manager import ConfigLoader, MobileConfig from mobilerun.log_handlers import CLILogHandler, configure_logging @@ -89,13 +88,9 @@ async def _run_task( api_url: str = DEFAULT_API_URL, no_delegate: bool = False, delegate_model: str | None = None, - base_url: str | None = None, - api_base: str | None = None, - **kwargs, ) -> tuple[bool, str | None, int | None]: """Execute a task and return the result-event fields.""" from mobilerun.agent.selectors import LlamaIndexLLMSelector - from mobilerun.cli.configure_wizard import ensure_llm_delegation from mobilerun.config_manager.config_manager import DecisionProfile config = ConfigLoader.load(config_path) @@ -156,10 +151,6 @@ async def _run_task( config.agent.streaming = stream if no_delegate: - if delegate_model: - raise click.UsageError( - "--delegate-model cannot be used with --no-delegate." - ) _drop_delegation(config) # A decision model picks actions in place of the action LLM. @@ -172,7 +163,7 @@ async def _run_task( else _action_role(config) ) existing = config.decision_profiles.get(role) - endpoint = llm_url or base_url or api_base + endpoint = llm_url configured = ( existing is not None and existing.provider.lower() == "typesafe" ) @@ -187,18 +178,15 @@ async def _run_task( model=model, base_url=endpoint ) # Endpoints given with -p belong to that provider, not the helper LLMs. - provider = model = llm_url = base_url = api_base = None + 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: - ensure_llm_delegation(config, role) + _apply_delegate_model(config) except ValueError as error: logger.warning(f"{error} Jev will run alone.") if delegate_model: - try: - _apply_delegate_model(config, delegate_model) - except ValueError as error: - raise click.UsageError(str(error)) from error + _apply_delegate_model(config, delegate_model) if not (provider or model) and config.agent.name in {"mobilerun", "droidrun"}: _log_delegate(config, logger) @@ -286,11 +274,7 @@ async def _run_task( if temperature is not None: llm_kwargs["temperature"] = temperature llm_kwargs.update(llm_url_kwargs(provider, llm_url)) - 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) + llm = load_llm(provider, model=model, **llm_kwargs) else: if llm_url is not None: for profile in config.llm_profiles.values(): @@ -298,12 +282,6 @@ async def _run_task( 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, @@ -337,6 +315,8 @@ async def _run_task( 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 @@ -370,17 +350,22 @@ def _drop_delegation(config: MobileConfig) -> None: config.agent.delegation = None -def _apply_delegate_model(config: MobileConfig, spec: str) -> None: +def _apply_delegate_model(config: MobileConfig, spec: str | None = None) -> None: from mobilerun.cli.configure_wizard import ensure_llm_delegation delegation = config.agent.delegation role = _action_role(config) - if ((delegation and delegation.primary) or role) not in config.decision_profiles: - raise ValueError( + if ( + spec + and ((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." ) - ensure_llm_delegation(config, role, _delegate_profile(config, role, spec)) + 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". @@ -394,13 +379,19 @@ def _delegate_profile(config: MobileConfig, role: str, spec: str) -> LLMProfile: 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"}: - # Only an exact provider name switches away from a vendor/model id. - is_provider = provider in _known_providers(exact=True) + # 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: @@ -408,7 +399,7 @@ def _delegate_profile(config: MobileConfig, role: str, spec: str) -> LLMProfile: return replace(base, model=model) return profile_for_provider(provider, model) if base is None: - raise ValueError( + raise click.UsageError( "--delegate-model needs PROVIDER/MODEL when the config has no agent LLM." ) return replace(base, model=spec) @@ -465,23 +456,21 @@ async def _cleanup_android_keyboard( @device_options @tcp_option @json_option -@click.option( - "-p", - "--provider", - type=click.Choice([*PROVIDERS, "TypeSafe"], case_sensitive=False), - help="LLM provider, or TypeSafe for the Jev decision model.", -) -@model_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", + 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)", + 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 @@ -526,6 +515,8 @@ async def run( """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, @@ -535,11 +526,7 @@ async def run( api_url=api_url, tcp=tcp, debug=verbose, - provider=( - "TypeSafe" - if provider == "TypeSafe" - else PROVIDERS[provider].runtime if provider else None - ), + provider=PROVIDERS[provider].runtime if provider in PROVIDERS else provider, model=model, llm_url=llm_url, steps=max_steps, diff --git a/tests/test_agent_model_loading.py b/tests/test_agent_model_loading.py index 8beca6a1..5165720d 100644 --- a/tests/test_agent_model_loading.py +++ b/tests/test_agent_model_loading.py @@ -9,7 +9,7 @@ from pydantic import BaseModel from selector_helpers import CompletedRun, StubSelector -import mobilerun.cli.run as cli_main +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 @@ -23,16 +23,16 @@ 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(cli_run, "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(cli_run, "MobileAgent", initialize) return agents @@ -564,18 +564,16 @@ def test_cli_custom_provider_retains_original_constructor_defaults( ): config = MobileConfig() load = Mock(return_value=MockLLM()) - monkeypatch.setattr(cli_main, "load_llm", load) - monkeypatch.setattr(cli_main.ConfigLoader, "load", lambda _: config) + monkeypatch.setattr(cli_run, "load_llm", load) + monkeypatch.setattr(cli_run.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 +583,10 @@ 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, } 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 +596,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 +605,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(cli_run.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_delegation_config.py b/tests/test_delegation_config.py index 98abfdef..17d31a83 100644 --- a/tests/test_delegation_config.py +++ b/tests/test_delegation_config.py @@ -7,7 +7,7 @@ from llama_index.core.llms.mock import MockLLM from selector_helpers import CompletedRun -import mobilerun.cli.run as cli_main +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 ( @@ -187,19 +187,19 @@ 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(cli_run, "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(cli_run, "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_typesafe_cli.py b/tests/test_typesafe_cli.py index 16fdb6d0..c03fe806 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 @@ -351,12 +352,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] @@ -787,3 +788,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() From 55a0f392dfd0c6a555a8b45339ebfeb5106f8a09 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Sat, 14 Nov 2026 03:19:35 +0100 Subject: [PATCH 58/59] fix(cli): bound retries and diagnostics --- README.md | 4 +- mobilerun/cli/device_lifecycle.py | 12 +- mobilerun/cli/doctor.py | 5 +- mobilerun/cli/providers.py | 17 ++ mobilerun/cli/run.py | 40 ++-- mobilerun/cli/selection.py | 11 +- mobilerun/macro/cli.py | 6 +- tests/test_agent_model_loading.py | 3 +- tests/test_cli_contract.py | 2 +- tests/test_cli_qa_fixes.py | 348 ++++++++++++++++++++++++++++++ tests/test_cli_runtime_policy.py | 11 +- tests/test_typesafe_cli.py | 7 +- 12 files changed, 433 insertions(+), 33 deletions(-) create mode 100644 tests/test_cli_qa_fixes.py diff --git a/README.md b/README.md index 73c87447..77c2fd2a 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ was used, or `"credential":"login"` when the saved sign-in was used. 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":"No adb exe could be found. Install adb on your system"}]} +{"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 @@ -325,6 +325,8 @@ listing fails, the other result is kept and a warning goes to stderr. It exits 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 diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index bb1642e8..c7b9d636 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio + import click from async_adbutils import adb @@ -36,14 +38,18 @@ ), ) @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, api_url, verbose, json_output, all_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: @@ -89,6 +95,10 @@ def _list_target(api_url, scope): 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(adb.list()) rows = [] for device in devices: rows.append( diff --git a/mobilerun/cli/doctor.py b/mobilerun/cli/doctor.py index 217af197..b8eeaf0b 100644 --- a/mobilerun/cli/doctor.py +++ b/mobilerun/cli/doctor.py @@ -172,8 +172,11 @@ async def check_config( async def check_adb(debug: bool) -> CheckResult: """Check ADB is available and list devices.""" + 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", diff --git a/mobilerun/cli/providers.py b/mobilerun/cli/providers.py index 4c732fb7..526db169 100644 --- a/mobilerun/cli/providers.py +++ b/mobilerun/cli/providers.py @@ -80,3 +80,20 @@ def llm_url_kwargs(runtime, url): {"base_url": url} if spec.url_keyword == "http_options" else url ) } + + +def llm_retry_kwargs(runtime): + """Leave retries to the agent instead of nesting OpenAI SDK/adapter retries.""" + from mobilerun.agent.utils.llm_picker import normalize_provider_name + + if normalize_provider_name(runtime) in { + "OpenAIResponses", + "OpenAILike", + "OpenRouter", + "DeepSeek", + "MiniMax", + "ZAI", + "openai_oauth", + }: + return {"max_retries": 0} + return {} diff --git a/mobilerun/cli/run.py b/mobilerun/cli/run.py index d4a7a7ff..f9525e9c 100644 --- a/mobilerun/cli/run.py +++ b/mobilerun/cli/run.py @@ -25,7 +25,12 @@ max_steps_option, tcp_option, ) -from mobilerun.cli.providers import PROVIDERS, llm_url_kwargs, run_provider_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 @@ -86,16 +91,28 @@ async def _run_task( """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 delegate_model and (provider or "").strip().lower() != "typesafe": + delegation = config.agent.delegation + action_reasoning = config.agent.reasoning if reasoning is None else reasoning + role = "executor" if action_reasoning else "fast_agent" + 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 import ConfigLoader from mobilerun.config_manager.config_manager import DecisionProfile from mobilerun.telemetry import print_telemetry_message - config = ConfigLoader.load(config_path) - # Print cloud link in a box if config.logging.rich_text: cloud_text = Text() @@ -271,12 +288,15 @@ async def _run_task( if provider or model: if not (provider and model): raise ValueError("--provider and --model must be given together") - llm_kwargs = {} + 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)) @@ -354,17 +374,7 @@ def _drop_delegation(config: MobileConfig) -> None: def _apply_delegate_model(config: MobileConfig, spec: str | None = None) -> None: from mobilerun.cli.configure_wizard import ensure_llm_delegation - delegation = config.agent.delegation role = _action_role(config) - if ( - spec - and ((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." - ) profile = _delegate_profile(config, role, spec) if spec else None ensure_llm_delegation(config, role, profile) diff --git a/mobilerun/cli/selection.py b/mobilerun/cli/selection.py index 65d30e00..0caf11f3 100644 --- a/mobilerun/cli/selection.py +++ b/mobilerun/cli/selection.py @@ -22,7 +22,12 @@ def local_adb_errors(enabled=True): try: yield - except (AdbError, ConnectionRefusedError, FileNotFoundError) as exc: + except ( + AdbError, + ConnectionRefusedError, + FileNotFoundError, + ModuleNotFoundError, + ) as exc: message = str(exc).lower() missing_binary = ( isinstance(exc, FileNotFoundError) @@ -35,6 +40,10 @@ def local_adb_errors(enabled=True): unavailable = ( isinstance(exc, ConnectionRefusedError) or missing_binary + or ( + isinstance(exc, ModuleNotFoundError) + and exc.name in {"adbutils", "adbutils.binaries"} + ) or ( isinstance(exc, AdbError) and any( diff --git a/mobilerun/macro/cli.py b/mobilerun/macro/cli.py index db7d87ab..2168422b 100644 --- a/mobilerun/macro/cli.py +++ b/mobilerun/macro/cli.py @@ -12,6 +12,7 @@ from mobilerun.cli.options import ( Group, + config_option, coro, device_options, emit, @@ -318,12 +319,15 @@ async def _show_dry_run( @macro_cli.command(epilog=examples("mobilerun macro list ./macros")) @click.argument("directory", type=click.Path(exists=True), default="trajectories") +@config_option @verbose_option @json_option -def list(directory: str, verbose: bool, json_output: bool): +def list(directory: str, config_path: Optional[str], verbose: bool, json_output: bool): """List available trajectory folders in a directory.""" 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) diff --git a/tests/test_agent_model_loading.py b/tests/test_agent_model_loading.py index 11e83e49..527df089 100644 --- a/tests/test_agent_model_loading.py +++ b/tests/test_agent_model_loading.py @@ -561,7 +561,7 @@ 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() @@ -585,6 +585,7 @@ def test_cli_custom_provider_retains_original_constructor_defaults( expected: dict[str, object] = { "model": "test", "base_url": "https://example.test", + "max_retries": 0, } if temperature is not None: expected["temperature"] = temperature diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index ff208dd7..bf2539c6 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -907,5 +907,5 @@ def test_device_commands_do_not_accept_tcp_or_unused_list_flags(): assert all( "--tcp" not in getattr(param, "opts", ()) for param in command.params ), path - for flag in ("-a", "-d", "-P", "-c"): + for flag in ("-a", "-d", "-P"): assert runner.invoke(cli, ["device", "list", flag, "x"]).exit_code == 2 diff --git a/tests/test_cli_qa_fixes.py b/tests/test_cli_qa_fixes.py new file mode 100644 index 00000000..aa41a304 --- /dev/null +++ b/tests/test_cli_qa_fixes.py @@ -0,0 +1,348 @@ +"""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 AdbError +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" + ), + AdbError("No adb exe could be found. Install adb on your system"), + ConnectionRefusedError("server unavailable"), + ], +) +@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", ""]) +@pytest.mark.parametrize("json_output", [False, True]) +def test_device_list_refuses_config_without_version( + monkeypatch, tmp_path, content, json_output +): + 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", *(["-j"] if json_output else [])] + ) + assert result.exit_code == 1, result.output + assert "outdated (missing _version)" in result.stderr + if json_output: + assert "outdated (missing _version)" in json.loads(result.stdout)["error"] + client.assert_not_called() + + +@pytest.mark.parametrize("provider", [[], ["-p", "openai", "-m", "x"]]) +@pytest.mark.parametrize("json_output", [False, True]) +def test_delegate_precondition_precedes_banner_and_agent_import( + monkeypatch, provider, json_output +): + import builtins + + real_import = builtins.__import__ + + def guarded(name, globals=None, locals=None, fromlist=(), level=0): + if name == "mobilerun" and "MobileAgent" in fromlist: + pytest.fail("agent imported before delegate precondition") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", guarded) + result = CliRunner().invoke( + cli, + [ + "run", + "x", + "--delegate-model", + "openai/x", + "-d", + "http://127.0.0.1:9", + "-P", + "android", + *provider, + *(["-j"] if json_output else []), + ], + ) + 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", "60") + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path.startswith("/state_full"): + self.reply( + { + "a11y_tree": { + "className": "android.widget.FrameLayout", + "children": [], + }, + "phone_state": { + "packageName": "com.android.settings", + "isEditable": False, + }, + "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", ["openai-like", "openai", "config"]) +@pytest.mark.parametrize("json_output", [False, True]) +def test_run_persistent_429_is_bounded_and_emits_one_document( + tmp_path, rate_limit_server, provider, json_output +): + import os + import subprocess + import sys + import time + + from mobilerun.config_manager.config_manager import LLMProfile + + url, requests = rate_limit_server + config = MobileConfig() + config.logging.save_trajectory = "none" + if provider == "config": + config.llm_profiles = { + role: LLMProfile( + provider="OpenAILike", + model="gpt-6.1-sol", + kwargs={"api_base": url + "/v1"}, + ) + for role in config.llm_profiles + } + path = ConfigLoader.save(config, tmp_path / "run.yaml") + env = { + key: value + for key, value in os.environ.items() + if not key.startswith( + ("MOBILERUN_", "DROIDRUN_", "OPENAI_", "GOOGLE_", "GEMINI_") + ) + and not key.endswith(("_API_KEY", "_TOKEN")) + } + env.update( + HOME=str(tmp_path), + MOBILERUN_TELEMETRY_ENABLED="false", + OPENAI_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, "-P", "android", "-s", "3"] + if provider != "config": + args.extend(["-p", provider, "-m", "gpt-6.1-sol", "-u", url + "/v1"]) + if json_output: + args.append("-j") + start = time.monotonic() + result = subprocess.run( + [sys.executable, "-c", "from mobilerun.cli.main import cli; cli()", *args], + env=env, + capture_output=True, + text=True, + timeout=20, + ) + assert time.monotonic() - start < 60 + assert result.returncode == 1, result.stdout + result.stderr + assert "429" in result.stderr + assert len(requests) == 3 + assert all( + path == ("/v1/responses" if provider == "openai" else "/v1/chat/completions") + for path in requests + ) + if json_output: + document = json.loads(result.stdout) + assert document["success"] is False + assert "429" in document["reason"] + else: + assert result.stdout == "" diff --git a/tests/test_cli_runtime_policy.py b/tests/test_cli_runtime_policy.py index 4b3be869..7641d1e8 100644 --- a/tests/test_cli_runtime_policy.py +++ b/tests/test_cli_runtime_policy.py @@ -83,8 +83,6 @@ def test_local_adb_missing_or_server_unreachable(monkeypatch, error, args): def test_local_adb_server_can_start_on_first_use(monkeypatch, tmp_path): - import asyncio - binary = tmp_path / "adb" binary.write_text("#!/bin/sh\nexit 0\n") binary.chmod(0o755) @@ -93,9 +91,7 @@ def test_local_adb_server_can_start_on_first_use(monkeypatch, tmp_path): # 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=lambda **kwargs: asyncio.run(listing()), close=Mock() - ) + 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 @@ -314,10 +310,7 @@ def test_readme_real_error_examples(): "Config", "ADB", ] - assert ( - document["checks"][-1]["detail"] - == "No adb exe could be found. Install adb on your system" - ) + assert document["checks"][-1]["detail"] == options.ADB_HINT def test_doctor_reports_library_adb_error(monkeypatch): diff --git a/tests/test_typesafe_cli.py b/tests/test_typesafe_cli.py index f372496d..5a12e6fb 100644 --- a/tests/test_typesafe_cli.py +++ b/tests/test_typesafe_cli.py @@ -173,7 +173,10 @@ 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": 0}, ) @@ -187,7 +190,7 @@ def test_delegate_model_on_the_agent_provider_keeps_its_settings(monkeypatch): ) 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": 0} ) From b31355de6eab59145889b62a2c7c5b9ea2214728 Mon Sep 17 00:00:00 2001 From: lutz-grex Date: Sat, 14 Nov 2026 05:25:10 +0100 Subject: [PATCH 59/59] fix(cli): review fixes qa round --- mobilerun/cli/device_lifecycle.py | 2 +- mobilerun/cli/providers.py | 37 ++++--- mobilerun/cli/run.py | 7 +- mobilerun/cli/selection.py | 6 +- tests/test_agent_model_loading.py | 2 +- tests/test_cli_qa_fixes.py | 173 ++++++++++++++---------------- tests/test_typesafe_cli.py | 6 +- 7 files changed, 113 insertions(+), 120 deletions(-) diff --git a/mobilerun/cli/device_lifecycle.py b/mobilerun/cli/device_lifecycle.py index c7b9d636..67aa2cc4 100644 --- a/mobilerun/cli/device_lifecycle.py +++ b/mobilerun/cli/device_lifecycle.py @@ -98,7 +98,7 @@ def _list_target(api_url, 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(adb.list()) + asyncio.run(asyncio.wait_for(adb.list(), 3)) rows = [] for device in devices: rows.append( diff --git a/mobilerun/cli/providers.py b/mobilerun/cli/providers.py index 526db169..216c89dd 100644 --- a/mobilerun/cli/providers.py +++ b/mobilerun/cli/providers.py @@ -83,17 +83,26 @@ def llm_url_kwargs(runtime, url): def llm_retry_kwargs(runtime): - """Leave retries to the agent instead of nesting OpenAI SDK/adapter retries.""" - from mobilerun.agent.utils.llm_picker import normalize_provider_name - - if normalize_provider_name(runtime) in { - "OpenAIResponses", - "OpenAILike", - "OpenRouter", - "DeepSeek", - "MiniMax", - "ZAI", - "openai_oauth", - }: - return {"max_retries": 0} - return {} + """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/run.py b/mobilerun/cli/run.py index f9525e9c..42cf212f 100644 --- a/mobilerun/cli/run.py +++ b/mobilerun/cli/run.py @@ -94,10 +94,11 @@ async def _run_task( 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 - action_reasoning = config.agent.reasoning if reasoning is None else reasoning - role = "executor" if action_reasoning else "fast_agent" + role = _action_role(config) if ( (delegation and delegation.primary) or role ) not in config.decision_profiles: @@ -163,8 +164,6 @@ async def _run_task( 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 diff --git a/mobilerun/cli/selection.py b/mobilerun/cli/selection.py index 0caf11f3..5cadef65 100644 --- a/mobilerun/cli/selection.py +++ b/mobilerun/cli/selection.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import os from contextlib import contextmanager from dataclasses import dataclass, field @@ -18,12 +19,13 @@ @contextmanager def local_adb_errors(enabled=True): """Translate local ADB startup failures after the library tries to start it.""" - from async_adbutils import AdbError + from async_adbutils import AdbError, AdbTimeout try: yield except ( AdbError, + asyncio.TimeoutError, ConnectionRefusedError, FileNotFoundError, ModuleNotFoundError, @@ -38,7 +40,7 @@ def local_adb_errors(enabled=True): ) ) unavailable = ( - isinstance(exc, ConnectionRefusedError) + isinstance(exc, (ConnectionRefusedError, asyncio.TimeoutError, AdbTimeout)) or missing_binary or ( isinstance(exc, ModuleNotFoundError) diff --git a/tests/test_agent_model_loading.py b/tests/test_agent_model_loading.py index 527df089..e0732a64 100644 --- a/tests/test_agent_model_loading.py +++ b/tests/test_agent_model_loading.py @@ -585,7 +585,7 @@ def test_cli_custom_provider_uses_bounded_retry_defaults( expected: dict[str, object] = { "model": "test", "base_url": "https://example.test", - "max_retries": 0, + "max_retries": 1, } if temperature is not None: expected["temperature"] = temperature diff --git a/tests/test_cli_qa_fixes.py b/tests/test_cli_qa_fixes.py index aa41a304..f2ffe47b 100644 --- a/tests/test_cli_qa_fixes.py +++ b/tests/test_cli_qa_fixes.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, Mock import pytest -from async_adbutils import AdbError +from async_adbutils import AdbTimeout from click.testing import CliRunner from mobilerun.cli import device_lifecycle, deviceauth, selection @@ -39,8 +39,6 @@ def isolated(monkeypatch, tmp_path): ModuleNotFoundError( "No module named 'adbutils.binaries'", name="adbutils.binaries" ), - AdbError("No adb exe could be found. Install adb on your system"), - ConnectionRefusedError("server unavailable"), ], ) @pytest.mark.parametrize( @@ -142,55 +140,20 @@ def test_listing_config_directory_is_a_shared_usage_error( @pytest.mark.parametrize("content", ["device:\n platform: android\n", ""]) -@pytest.mark.parametrize("json_output", [False, True]) -def test_device_list_refuses_config_without_version( - monkeypatch, tmp_path, content, json_output -): +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", *(["-j"] if json_output else [])] - ) + result = CliRunner().invoke(cli, ["device", "list"]) assert result.exit_code == 1, result.output assert "outdated (missing _version)" in result.stderr - if json_output: - assert "outdated (missing _version)" in json.loads(result.stdout)["error"] client.assert_not_called() -@pytest.mark.parametrize("provider", [[], ["-p", "openai", "-m", "x"]]) -@pytest.mark.parametrize("json_output", [False, True]) -def test_delegate_precondition_precedes_banner_and_agent_import( - monkeypatch, provider, json_output -): - import builtins - - real_import = builtins.__import__ - - def guarded(name, globals=None, locals=None, fromlist=(), level=0): - if name == "mobilerun" and "MobileAgent" in fromlist: - pytest.fail("agent imported before delegate precondition") - return real_import(name, globals, locals, fromlist, level) - - monkeypatch.setattr(builtins, "__import__", guarded) - result = CliRunner().invoke( - cli, - [ - "run", - "x", - "--delegate-model", - "openai/x", - "-d", - "http://127.0.0.1:9", - "-P", - "android", - *provider, - *(["-j"] if json_output else []), - ], - ) +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 @@ -227,25 +190,13 @@ def reply(self, value, status=200): self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) if status == 429: - self.send_header("Retry-After", "60") + 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( - { - "a11y_tree": { - "className": "android.widget.FrameLayout", - "children": [], - }, - "phone_state": { - "packageName": "com.android.settings", - "isEditable": False, - }, - "device_context": {}, - } - ) + self.reply(dict(a11y_tree={}, phone_state={}, device_context={})) else: self.reply({"version": "0.7.25"}) @@ -276,73 +227,105 @@ def do_POST(self): thread.join() -@pytest.mark.parametrize("provider", ["openai-like", "openai", "config"]) -@pytest.mark.parametrize("json_output", [False, True]) +@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, json_output + tmp_path, rate_limit_server, provider, model ): import os import subprocess import sys - import time - - from mobilerun.config_manager.config_manager import LLMProfile url, requests = rate_limit_server - config = MobileConfig() - config.logging.save_trajectory = "none" - if provider == "config": - config.llm_profiles = { - role: LLMProfile( - provider="OpenAILike", - model="gpt-6.1-sol", - kwargs={"api_base": url + "/v1"}, - ) - for role in config.llm_profiles - } - path = ConfigLoader.save(config, tmp_path / "run.yaml") + 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_", "GOOGLE_", "GEMINI_") - ) + 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, "-P", "android", "-s", "3"] - if provider != "config": - args.extend(["-p", provider, "-m", "gpt-6.1-sol", "-u", url + "/v1"]) - if json_output: - args.append("-j") - start = time.monotonic() + 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=20, + timeout=25, ) - assert time.monotonic() - start < 60 assert result.returncode == 1, result.stdout + result.stderr assert "429" in result.stderr - assert len(requests) == 3 - assert all( - path == ("/v1/responses" if provider == "openai" else "/v1/chat/completions") - for path in requests + 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" ) - if json_output: - document = json.loads(result.stdout) - assert document["success"] is False - assert "429" in document["reason"] - else: - assert result.stdout == "" + 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_typesafe_cli.py b/tests/test_typesafe_cli.py index 5a12e6fb..d22e694a 100644 --- a/tests/test_typesafe_cli.py +++ b/tests/test_typesafe_cli.py @@ -176,21 +176,21 @@ def test_delegate_model_alone_keeps_the_agent_provider(monkeypatch): provider="OpenAILike", model="org/model-b", base_url="https://llm.test", - kwargs={"max_retries": 0}, + 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, "max_retries": 0} + provider="OpenAI", model="gpt-6-luna", kwargs={"timeout": 30, "max_retries": 4} )