From fb297f793713cbec7293195d80021abecc50f89a Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Wed, 11 Feb 2026 03:38:37 -0700 Subject: [PATCH] Add pytest suite (216 tests) and fix UI/notification parser bugs Test infrastructure with conftest fixtures mocking run_shell_args/run_adb for device-free testing across all 8 mixins. Fixed: UI parser regex couldn't match hyphenated XML attributes (content-desc, resource-id). Notification parser captured trailing parenthesis in package names. --- pyproject.toml | 3 + src/mixins/settings.py | 2 +- src/mixins/ui.py | 2 +- tests/__init__.py | 0 tests/conftest.py | 124 ++++++++++++ tests/test_apps.py | 233 ++++++++++++++++++++++ tests/test_base.py | 234 ++++++++++++++++++++++ tests/test_config.py | 78 ++++++++ tests/test_connectivity.py | 122 ++++++++++++ tests/test_devices.py | 181 +++++++++++++++++ tests/test_files.py | 120 ++++++++++++ tests/test_input.py | 236 ++++++++++++++++++++++ tests/test_models.py | 56 ++++++ tests/test_screenshot.py | 132 +++++++++++++ tests/test_server.py | 36 ++++ tests/test_settings.py | 391 +++++++++++++++++++++++++++++++++++++ tests/test_ui.py | 141 +++++++++++++ 17 files changed, 2089 insertions(+), 2 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_apps.py create mode 100644 tests/test_base.py create mode 100644 tests/test_config.py create mode 100644 tests/test_connectivity.py create mode 100644 tests/test_devices.py create mode 100644 tests/test_files.py create mode 100644 tests/test_input.py create mode 100644 tests/test_models.py create mode 100644 tests/test_screenshot.py create mode 100644 tests/test_server.py create mode 100644 tests/test_settings.py create mode 100644 tests/test_ui.py diff --git a/pyproject.toml b/pyproject.toml index ed90c23..336d9d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,9 @@ line-length = 88 [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "SIM"] +[tool.pytest.ini_options] +asyncio_mode = "auto" + [tool.mypy] python_version = "3.11" strict = true diff --git a/src/mixins/settings.py b/src/mixins/settings.py index 9c8a265..848a94b 100644 --- a/src/mixins/settings.py +++ b/src/mixins/settings.py @@ -513,7 +513,7 @@ class SettingsMixin(ADBBaseMixin): break current = {} # Extract package from NotificationRecord line - pkg_match = re.search(r"pkg=(\S+)", stripped) + pkg_match = re.search(r"pkg=([\w.]+)", stripped) if pkg_match: current["package"] = pkg_match.group(1) diff --git a/src/mixins/ui.py b/src/mixins/ui.py index 1e0f248..8f32dea 100644 --- a/src/mixins/ui.py +++ b/src/mixins/ui.py @@ -102,7 +102,7 @@ class UIMixin(ADBBaseMixin): # Regex to find node elements with their attributes node_pattern = re.compile(r"]+?)(?:/>|>)", re.DOTALL) - attr_pattern = re.compile(r'(\w+)="([^"]*)"') + attr_pattern = re.compile(r'([\w-]+)="([^"]*)"') for match in node_pattern.finditer(xml_content): attrs_str = match.group(1) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a5c7780 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,124 @@ +"""Shared test fixtures for mcadb tests.""" + +from dataclasses import dataclass, field +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from src.models import CommandResult +from src.server import ADBServer + +# --- Helpers --- + + +def ok(stdout: str = "", stderr: str = "") -> CommandResult: + """Create a successful CommandResult.""" + return CommandResult(success=True, stdout=stdout, stderr=stderr, returncode=0) + + +def fail(stderr: str = "error", stdout: str = "") -> CommandResult: + """Create a failed CommandResult.""" + return CommandResult(success=False, stdout=stdout, stderr=stderr, returncode=1) + + +# --- Mock Context --- + + +@dataclass +class ElicitResult: + """Minimal stand-in for FastMCP's ElicitationResult.""" + + action: str = "accept" + content: str = "" + + +@dataclass +class MockContext: + """Mock MCP Context that records calls for assertion.""" + + messages: list[tuple[str, str]] = field(default_factory=list) + _elicit_response: ElicitResult = field(default_factory=ElicitResult) + + async def info(self, msg: str) -> None: + self.messages.append(("info", msg)) + + async def warning(self, msg: str) -> None: + self.messages.append(("warning", msg)) + + async def error(self, msg: str) -> None: + self.messages.append(("error", msg)) + + async def elicit(self, msg: str, options: list[str] | None = None) -> ElicitResult: + self.messages.append(("elicit", msg)) + return self._elicit_response + + def set_elicit(self, action: str = "accept", content: str = "") -> None: + """Configure the next elicit response.""" + self._elicit_response = ElicitResult(action=action, content=content) + + +# --- Fixtures --- + + +def _reset_config(monkeypatch: pytest.MonkeyPatch, config_dir: Any) -> None: + """Reset the Config singleton and point it at a temp directory. + + CONFIG_DIR and CONFIG_FILE are module-level variables computed at + import time, so setting the env var isn't enough — we must patch + the variables directly. + """ + from pathlib import Path + + config_path = Path(config_dir) + monkeypatch.setattr("src.config.Config._instance", None) + monkeypatch.setattr("src.config.CONFIG_DIR", config_path) + monkeypatch.setattr("src.config.CONFIG_FILE", config_path / "config.json") + + +@pytest.fixture +def server() -> ADBServer: + """Create an ADBServer with mocked ADB execution. + + Both run_adb and run_shell_args are replaced with AsyncMock, + so no real subprocess calls are made. Configure return values + per-test with server.run_adb.return_value = ok("..."). + """ + s = ADBServer() + s.run_adb = AsyncMock(return_value=ok()) # type: ignore[method-assign] + s.run_shell_args = AsyncMock(return_value=ok()) # type: ignore[method-assign] + s.run_shell = AsyncMock(return_value=ok()) # type: ignore[method-assign] + s.get_device_property = AsyncMock(return_value=None) # type: ignore[method-assign] + return s + + +@pytest.fixture +def ctx() -> MockContext: + """Create a mock MCP Context.""" + return MockContext() + + +@pytest.fixture +def _dev_mode(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: + """Enable developer mode for the test.""" + _reset_config(monkeypatch, tmp_path / "dev-config") + from src.config import get_config + + config = get_config() + config._settings["developer_mode"] = True + + +@pytest.fixture +def _no_dev_mode(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: + """Disable developer mode for the test.""" + _reset_config(monkeypatch, tmp_path / "nodev-config") + from src.config import get_config + + config = get_config() + config._settings["developer_mode"] = False + + +@pytest.fixture(autouse=True) +def _isolate_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: + """Isolate config to a temp directory so tests don't touch real config.""" + _reset_config(monkeypatch, tmp_path / "config") diff --git a/tests/test_apps.py b/tests/test_apps.py new file mode 100644 index 0000000..652ee9c --- /dev/null +++ b/tests/test_apps.py @@ -0,0 +1,233 @@ +"""Tests for apps mixin (launch, close, current, install, intents).""" + +import pytest + +from tests.conftest import fail, ok + + +class TestAppLaunch: + async def test_launch(self, server): + server.run_shell_args.return_value = ok() + result = await server.app_launch("com.android.chrome") + assert result["success"] is True + assert result["package"] == "com.android.chrome" + args = server.run_shell_args.call_args[0][0] + assert "monkey" in args + assert "com.android.chrome" in args + + async def test_failure(self, server): + server.run_shell_args.return_value = fail("not found") + result = await server.app_launch("com.missing.app") + assert result["success"] is False + + +class TestAppOpenUrl: + async def test_open(self, server): + server.run_shell_args.return_value = ok() + result = await server.app_open_url("https://example.com") + assert result["success"] is True + assert result["url"] == "https://example.com" + args = server.run_shell_args.call_args[0][0] + assert "am" in args + assert "android.intent.action.VIEW" in args + + +class TestAppClose: + async def test_close(self, server): + server.run_shell_args.return_value = ok() + result = await server.app_close("com.example.app") + assert result["success"] is True + assert result["package"] == "com.example.app" + args = server.run_shell_args.call_args[0][0] + assert "am" in args + assert "force-stop" in args + + +class TestAppCurrent: + async def test_parse_focused(self, server): + focused = ( + " mCurrentFocus=Window{abc com.android.chrome" + "/org.chromium.chrome.browser.ChromeTabbedActivity}" + ) + server.run_shell_args.return_value = ok(stdout=focused) + result = await server.app_current() + assert result["success"] is True + assert result["package"] == "com.android.chrome" + + async def test_focused_app_format(self, server): + server.run_shell_args.return_value = ok( + stdout=" mFocusedApp=ActivityRecord{abc com.example/.MainActivity t123}" + ) + result = await server.app_current() + assert result["success"] is True + assert result["package"] == "com.example" + + async def test_no_focus(self, server): + server.run_shell_args.return_value = ok(stdout="no focus info") + result = await server.app_current() + assert result["success"] is True + assert result["package"] is None + + +class TestAppListPackages: + @pytest.mark.usefixtures("_dev_mode") + async def test_list(self, server): + server.run_shell_args.return_value = ok( + stdout="package:com.android.chrome\npackage:com.example.app\n" + ) + result = await server.app_list_packages() + assert result["success"] is True + assert result["count"] == 2 + assert "com.android.chrome" in result["packages"] + + @pytest.mark.usefixtures("_dev_mode") + async def test_filter(self, server): + server.run_shell_args.return_value = ok( + stdout="package:com.android.chrome\npackage:com.example.app\n" + ) + result = await server.app_list_packages(filter_text="chrome") + assert result["count"] == 1 + assert "com.android.chrome" in result["packages"] + + @pytest.mark.usefixtures("_dev_mode") + async def test_third_party(self, server): + server.run_shell_args.return_value = ok(stdout="package:com.user.app\n") + await server.app_list_packages(third_party_only=True) + args = server.run_shell_args.call_args[0][0] + assert "-3" in args + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server): + result = await server.app_list_packages() + assert result["success"] is False + + +class TestAppInstall: + @pytest.mark.usefixtures("_dev_mode") + async def test_install(self, server): + server.run_adb.return_value = ok(stdout="Success") + result = await server.app_install("/tmp/app.apk") + assert result["success"] is True + args = server.run_adb.call_args[0][0] + assert "install" in args + assert "-r" in args + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server): + result = await server.app_install("/tmp/app.apk") + assert result["success"] is False + + +class TestAppUninstall: + @pytest.mark.usefixtures("_dev_mode") + async def test_uninstall(self, server, ctx): + ctx.set_elicit("accept", "Yes, uninstall") + server.run_adb.return_value = ok() + result = await server.app_uninstall(ctx, "com.example.app") + assert result["success"] is True + assert result["package"] == "com.example.app" + + @pytest.mark.usefixtures("_dev_mode") + async def test_keep_data(self, server, ctx): + ctx.set_elicit("accept", "Yes, uninstall") + server.run_adb.return_value = ok() + result = await server.app_uninstall(ctx, "com.example.app", keep_data=True) + assert result["kept_data"] is True + args = server.run_adb.call_args[0][0] + assert "-k" in args + + @pytest.mark.usefixtures("_dev_mode") + async def test_cancelled(self, server, ctx): + ctx.set_elicit("accept", "Cancel") + result = await server.app_uninstall(ctx, "com.example.app") + assert result["success"] is False + assert result.get("cancelled") is True + + +class TestAppClearData: + @pytest.mark.usefixtures("_dev_mode") + async def test_clear(self, server, ctx): + ctx.set_elicit("accept", "Yes, clear all data") + server.run_shell_args.return_value = ok() + result = await server.app_clear_data(ctx, "com.example.app") + assert result["success"] is True + + @pytest.mark.usefixtures("_dev_mode") + async def test_cancelled(self, server, ctx): + ctx.set_elicit("accept", "Cancel") + result = await server.app_clear_data(ctx, "com.example.app") + assert result.get("cancelled") is True + + +class TestActivityStart: + @pytest.mark.usefixtures("_dev_mode") + async def test_basic(self, server): + server.run_shell_args.return_value = ok() + result = await server.activity_start("com.example/.MainActivity") + assert result["success"] is True + assert result["component"] == "com.example/.MainActivity" + args = server.run_shell_args.call_args[0][0] + assert "am" in args + assert "start" in args + assert "-n" in args + + @pytest.mark.usefixtures("_dev_mode") + async def test_with_action_and_data(self, server): + server.run_shell_args.return_value = ok() + await server.activity_start( + "com.example/.DeepLink", + action="android.intent.action.VIEW", + data_uri="myapp://product/123", + ) + args = server.run_shell_args.call_args[0][0] + assert "-a" in args + assert "-d" in args + + @pytest.mark.usefixtures("_dev_mode") + async def test_with_extras(self, server): + server.run_shell_args.return_value = ok() + await server.activity_start( + "com.example/.Act", + extras={"key": "value", "flag": "true", "count": "42"}, + ) + args = server.run_shell_args.call_args[0][0] + assert "--es" in args # string extra + assert "--ez" in args # boolean extra + assert "--ei" in args # integer extra + + @pytest.mark.usefixtures("_dev_mode") + async def test_with_flags(self, server): + server.run_shell_args.return_value = ok() + await server.activity_start( + "com.example/.Act", + flags=["FLAG_ACTIVITY_NEW_TASK"], + ) + args = server.run_shell_args.call_args[0][0] + assert "-f" in args + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server): + result = await server.activity_start("com.example/.Act") + assert result["success"] is False + + +class TestBroadcastSend: + @pytest.mark.usefixtures("_dev_mode") + async def test_basic(self, server): + server.run_shell_args.return_value = ok() + result = await server.broadcast_send("com.example.ACTION") + assert result["success"] is True + assert result["broadcast_action"] == "com.example.ACTION" + + @pytest.mark.usefixtures("_dev_mode") + async def test_with_package(self, server): + server.run_shell_args.return_value = ok() + await server.broadcast_send("ACTION", package="com.target") + args = server.run_shell_args.call_args[0][0] + assert "-p" in args + assert "com.target" in args + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server): + result = await server.broadcast_send("ACTION") + assert result["success"] is False diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..401f545 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,234 @@ +"""Tests for base ADB execution mixin.""" + +import shlex +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.mixins.base import ADBBaseMixin +from src.models import CommandResult + + +class TestRunAdb: + @pytest.fixture + def base(self): + return ADBBaseMixin() + + async def test_basic_command(self, base): + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (b"output\n", b"") + mock_proc.returncode = 0 + + patcher = patch( + "asyncio.create_subprocess_exec", + return_value=mock_proc, + ) + with patcher as mock_exec: + result = await base.run_adb(["devices"]) + + mock_exec.assert_called_once_with( + "adb", + "devices", + stdout=-1, + stderr=-1, + ) + assert result.success is True + assert result.stdout == "output" + + async def test_device_targeting(self, base): + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (b"ok", b"") + mock_proc.returncode = 0 + + patcher = patch( + "asyncio.create_subprocess_exec", + return_value=mock_proc, + ) + with patcher as mock_exec: + await base.run_adb(["shell", "ls"], device_id="ABC123") + + # Should insert -s ABC123 before the command + mock_exec.assert_called_once_with( + "adb", + "-s", + "ABC123", + "shell", + "ls", + stdout=-1, + stderr=-1, + ) + + async def test_current_device_fallback(self, base): + base.set_current_device("DEF456") + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (b"ok", b"") + mock_proc.returncode = 0 + + patcher = patch( + "asyncio.create_subprocess_exec", + return_value=mock_proc, + ) + with patcher as mock_exec: + await base.run_adb(["devices"]) + + args = mock_exec.call_args[0] + assert "-s" in args + assert "DEF456" in args + + async def test_device_id_overrides_current(self, base): + base.set_current_device("OLD") + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (b"ok", b"") + mock_proc.returncode = 0 + + patcher = patch( + "asyncio.create_subprocess_exec", + return_value=mock_proc, + ) + with patcher as mock_exec: + await base.run_adb(["shell", "ls"], device_id="NEW") + + args = mock_exec.call_args[0] + assert "NEW" in args + assert "OLD" not in args + + async def test_failure(self, base): + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (b"", b"not found") + mock_proc.returncode = 1 + + with patch("asyncio.create_subprocess_exec", return_value=mock_proc): + result = await base.run_adb(["shell", "missing"]) + + assert result.success is False + assert result.stderr == "not found" + assert result.returncode == 1 + + async def test_timeout(self, base): + mock_proc = AsyncMock() + mock_proc.communicate.side_effect = TimeoutError() + mock_proc.kill = MagicMock() + + with patch("asyncio.create_subprocess_exec", return_value=mock_proc): + result = await base.run_adb(["shell", "hang"], timeout=1) + + assert result.success is False + assert "timed out" in result.stderr + assert result.returncode == -1 + + async def test_exception(self, base): + with patch( + "asyncio.create_subprocess_exec", + side_effect=FileNotFoundError("adb not found"), + ): + result = await base.run_adb(["devices"]) + + assert result.success is False + assert "adb not found" in result.stderr + + +class TestRunShellArgs: + @pytest.fixture + def base(self): + return ADBBaseMixin() + + async def test_quotes_arguments(self, base): + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (b"ok", b"") + mock_proc.returncode = 0 + + patcher = patch( + "asyncio.create_subprocess_exec", + return_value=mock_proc, + ) + with patcher as mock_exec: + await base.run_shell_args(["input", "text", "hello world"]) + + args = mock_exec.call_args[0] + # "shell" should be in the args + assert "shell" in args + # Arguments should be shlex-quoted + quoted_hello = shlex.quote("hello world") + assert quoted_hello in args + + async def test_injection_safety(self, base): + """Verify dangerous characters get quoted.""" + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (b"ok", b"") + mock_proc.returncode = 0 + + patcher = patch( + "asyncio.create_subprocess_exec", + return_value=mock_proc, + ) + with patcher as mock_exec: + await base.run_shell_args(["echo", "; rm -rf /"]) + + args = mock_exec.call_args[0] + # The dangerous string should be quoted, not bare + assert "; rm -rf /" not in args + quoted = shlex.quote("; rm -rf /") + assert quoted in args + + +class TestRunShell: + @pytest.fixture + def base(self): + return ADBBaseMixin() + + async def test_splits_command(self, base): + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (b"ok", b"") + mock_proc.returncode = 0 + + patcher = patch( + "asyncio.create_subprocess_exec", + return_value=mock_proc, + ) + with patcher as mock_exec: + await base.run_shell("ls -la /sdcard") + + args = mock_exec.call_args[0] + assert "shell" in args + assert "ls" in args + assert "-la" in args + assert "/sdcard" in args + + +class TestGetDeviceProperty: + @pytest.fixture + def base(self): + b = ADBBaseMixin() + b.run_shell_args = AsyncMock() # type: ignore[method-assign] + return b + + async def test_returns_value(self, base): + base.run_shell_args.return_value = CommandResult( + success=True, stdout="Pixel 6", stderr="", returncode=0 + ) + result = await base.get_device_property("ro.product.model") + assert result == "Pixel 6" + + async def test_returns_none_on_empty(self, base): + base.run_shell_args.return_value = CommandResult( + success=True, stdout="", stderr="", returncode=0 + ) + result = await base.get_device_property("ro.missing") + assert result is None + + async def test_returns_none_on_failure(self, base): + base.run_shell_args.return_value = CommandResult( + success=False, stdout="", stderr="err", returncode=1 + ) + result = await base.get_device_property("ro.missing") + assert result is None + + +class TestDeviceState: + def test_set_get_device(self): + base = ADBBaseMixin() + assert base.get_current_device() is None + base.set_current_device("ABC") + assert base.get_current_device() == "ABC" + base.set_current_device(None) + assert base.get_current_device() is None diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..ceaf154 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,78 @@ +"""Tests for configuration management.""" + +import json +from pathlib import Path + +from src.config import get_config, is_developer_mode + + +def _fresh_config(monkeypatch, config_dir): + """Reset singleton and point Config at a specific directory.""" + config_path = Path(config_dir) + monkeypatch.setattr("src.config.Config._instance", None) + monkeypatch.setattr("src.config.CONFIG_DIR", config_path) + monkeypatch.setattr("src.config.CONFIG_FILE", config_path / "config.json") + + +class TestConfig: + def test_defaults(self, tmp_path, monkeypatch): + _fresh_config(monkeypatch, tmp_path) + config = get_config() + assert config.developer_mode is False + assert config.auto_select_single_device is True + assert config.default_screenshot_dir is None + + def test_developer_mode_toggle(self, tmp_path, monkeypatch): + _fresh_config(monkeypatch, tmp_path) + config = get_config() + assert config.developer_mode is False + config.developer_mode = True + assert config.developer_mode is True + + def test_persistence(self, tmp_path, monkeypatch): + _fresh_config(monkeypatch, tmp_path) + config = get_config() + config.developer_mode = True + + config_file = tmp_path / "config.json" + assert config_file.exists() + data = json.loads(config_file.read_text()) + assert data["developer_mode"] is True + + def test_screenshot_dir(self, tmp_path, monkeypatch): + _fresh_config(monkeypatch, tmp_path) + config = get_config() + config.default_screenshot_dir = "/tmp/shots" + assert config.default_screenshot_dir == "/tmp/shots" + + def test_get_set(self, tmp_path, monkeypatch): + _fresh_config(monkeypatch, tmp_path) + config = get_config() + config.set("custom_key", "custom_value") + assert config.get("custom_key") == "custom_value" + + def test_to_dict(self, tmp_path, monkeypatch): + _fresh_config(monkeypatch, tmp_path) + config = get_config() + d = config.to_dict() + assert "developer_mode" in d + assert "auto_select_single_device" in d + + def test_load_corrupt_file(self, tmp_path, monkeypatch): + _fresh_config(monkeypatch, tmp_path) + (tmp_path / "config.json").write_text("{invalid json") + # Need a fresh singleton to trigger _load with corrupt file + monkeypatch.setattr("src.config.Config._instance", None) + config = get_config() + assert config.developer_mode is False + + +class TestIsDeveloperMode: + def test_off_by_default(self, tmp_path, monkeypatch): + _fresh_config(monkeypatch, tmp_path) + assert is_developer_mode() is False + + def test_on_when_enabled(self, tmp_path, monkeypatch): + _fresh_config(monkeypatch, tmp_path) + get_config().developer_mode = True + assert is_developer_mode() is True diff --git a/tests/test_connectivity.py b/tests/test_connectivity.py new file mode 100644 index 0000000..607f840 --- /dev/null +++ b/tests/test_connectivity.py @@ -0,0 +1,122 @@ +"""Tests for connectivity mixin (connect, disconnect, tcpip, pair, properties).""" + +import pytest + +from tests.conftest import fail, ok + + +class TestAdbConnect: + async def test_success(self, server): + server.run_adb.return_value = ok(stdout="connected to 10.0.0.1:5555") + result = await server.adb_connect("10.0.0.1") + assert result["success"] is True + assert result["address"] == "10.0.0.1:5555" + server.run_adb.assert_called_once_with(["connect", "10.0.0.1:5555"]) + + async def test_custom_port(self, server): + server.run_adb.return_value = ok(stdout="connected to 10.0.0.1:5556") + result = await server.adb_connect("10.0.0.1", port=5556) + assert result["address"] == "10.0.0.1:5556" + + async def test_already_connected(self, server): + server.run_adb.return_value = ok(stdout="already connected to 10.0.0.1:5555") + result = await server.adb_connect("10.0.0.1") + assert result["success"] is True + assert result["already_connected"] is True + + async def test_failure(self, server): + server.run_adb.return_value = ok(stdout="failed to connect") + result = await server.adb_connect("10.0.0.1") + assert result["success"] is False + + +class TestAdbDisconnect: + async def test_success(self, server): + server.run_adb.return_value = ok(stdout="disconnected 10.0.0.1:5555") + result = await server.adb_disconnect("10.0.0.1") + assert result["success"] is True + assert result["address"] == "10.0.0.1:5555" + + async def test_failure(self, server): + server.run_adb.return_value = ok(stdout="error: no such device") + result = await server.adb_disconnect("10.0.0.1") + assert result["success"] is False + + +class TestAdbTcpip: + @pytest.mark.usefixtures("_dev_mode") + async def test_success(self, server, ctx): + server.run_shell_args.return_value = ok( + stdout="10: wlan0 inet 192.168.1.100/24" + ) + server.run_adb.return_value = ok(stdout="restarting in TCP mode port: 5555") + result = await server.adb_tcpip(ctx) + assert result["success"] is True + assert result["device_ip"] == "192.168.1.100" + assert result["connect_address"] == "192.168.1.100:5555" + + @pytest.mark.usefixtures("_dev_mode") + async def test_rejects_network_device(self, server, ctx): + server.set_current_device("10.20.0.25:5555") + result = await server.adb_tcpip(ctx) + assert result["success"] is False + assert "already a network device" in result["error"] + + @pytest.mark.usefixtures("_dev_mode") + async def test_no_wifi_ip(self, server, ctx): + server.run_shell_args.return_value = ok(stdout="wlan0: no ip") + result = await server.adb_tcpip(ctx) + assert result["success"] is False + assert "WiFi" in result["error"] + + @pytest.mark.usefixtures("_dev_mode") + async def test_custom_port(self, server, ctx): + server.run_shell_args.return_value = ok( + stdout="10: wlan0 inet 192.168.1.50/24" + ) + server.run_adb.return_value = ok() + result = await server.adb_tcpip(ctx, port=5556) + assert result["port"] == 5556 + assert result["connect_address"] == "192.168.1.50:5556" + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server, ctx): + result = await server.adb_tcpip(ctx) + assert result["success"] is False + assert "developer mode" in result["error"].lower() + + +class TestAdbPair: + async def test_success(self, server): + server.run_adb.return_value = ok(stdout="Successfully paired to 10.0.0.1:37000") + result = await server.adb_pair("10.0.0.1", 37000, "123456") + assert result["success"] is True + server.run_adb.assert_called_once_with(["pair", "10.0.0.1:37000", "123456"]) + + async def test_failure(self, server): + server.run_adb.return_value = fail("Failed: wrong code") + result = await server.adb_pair("10.0.0.1", 37000, "000000") + assert result["success"] is False + + +class TestDeviceProperties: + async def test_returns_properties(self, server): + props = { + "ro.product.model": "Pixel 6", + "ro.product.manufacturer": "Google", + "ro.build.version.release": "14", + "ro.build.version.sdk": "34", + "ro.board.platform": "gs101", + } + server.get_device_property.side_effect = lambda p, d=None: props.get(p) + result = await server.device_properties() + assert result["success"] is True + assert result["identity"]["model"] == "Pixel 6" + assert result["software"]["android_version"] == "14" + assert result["hardware"]["chipset"] == "gs101" + + async def test_no_properties(self, server): + server.get_device_property.return_value = None + result = await server.device_properties() + assert result["success"] is False + assert "No properties" in result["error"] diff --git a/tests/test_devices.py b/tests/test_devices.py new file mode 100644 index 0000000..0e45999 --- /dev/null +++ b/tests/test_devices.py @@ -0,0 +1,181 @@ +"""Tests for devices mixin (list, use, current, info, reboot, logcat).""" + +import pytest + +from tests.conftest import fail, ok + + +class TestDevicesList: + async def test_parse_devices(self, server): + server.run_adb.return_value = ok( + stdout=( + "List of devices attached\n" + "ABC123\tdevice\tmodel:Pixel_6 product:oriole\n" + "10.20.0.25:5555\tdevice\tmodel:K2401 product:K2401\n" + ) + ) + devices = await server.devices_list() + assert len(devices) == 2 + assert devices[0].device_id == "ABC123" + assert devices[0].model == "Pixel_6" + assert devices[1].device_id == "10.20.0.25:5555" + + async def test_empty(self, server): + server.run_adb.return_value = ok(stdout="List of devices attached\n") + devices = await server.devices_list() + assert len(devices) == 0 + + async def test_failure(self, server): + server.run_adb.return_value = fail("adb not found") + devices = await server.devices_list() + assert len(devices) == 0 + + +class TestDevicesUse: + async def test_select_device(self, server): + server.run_adb.return_value = ok( + stdout="List of devices attached\nABC123\tdevice\n" + ) + result = await server.devices_use("ABC123") + assert result["success"] is True + assert server.get_current_device() == "ABC123" + + async def test_not_found(self, server): + server.run_adb.return_value = ok( + stdout="List of devices attached\nOTHER\tdevice\n" + ) + result = await server.devices_use("MISSING") + assert result["success"] is False + assert "not found" in result["error"] + + async def test_offline_device(self, server): + server.run_adb.return_value = ok( + stdout="List of devices attached\nABC123\toffline\n" + ) + result = await server.devices_use("ABC123") + assert result["success"] is False + assert "offline" in result["error"] + + +class TestDevicesCurrent: + async def test_no_device_set(self, server): + server.run_adb.return_value = ok(stdout="List of devices attached\n") + result = await server.devices_current() + assert result["device"] is None + + async def test_auto_detect_single(self, server): + server.run_adb.return_value = ok( + stdout="List of devices attached\nABC123\tdevice\n" + ) + result = await server.devices_current() + assert result.get("available") is not None + + async def test_device_set(self, server): + # Pre-populate cache and set device + server.run_adb.return_value = ok( + stdout="List of devices attached\nABC123\tdevice\tmodel:Pixel\n" + ) + await server.devices_list() + server.set_current_device("ABC123") + result = await server.devices_current() + assert result["device"]["device_id"] == "ABC123" + + +class TestDeviceInfo: + async def test_full_info(self, server): + battery = ( + "Current Battery Service state:\n level: 85\n status: 2\n plugged: 2" + ) + df_out = ( + "Filesystem 1K-blocks Used Available\n" + "/data 64000000 32000000 32000000" + ) + server.run_shell_args.side_effect = [ + ok(stdout=battery), + ok(stdout="10: wlan0 inet 192.168.1.100/24"), + ok(stdout="mWifiInfo SSID: MyNetwork, BSSID: ..."), + ok(stdout=df_out), + ] + server.get_device_property.side_effect = lambda p, d=None: { + "ro.build.version.release": "14", + "ro.build.version.sdk": "34", + "ro.product.model": "Pixel 6", + "ro.product.manufacturer": "Google", + "ro.product.device": "oriole", + }.get(p) + + result = await server.device_info() + assert result["success"] is True + assert result["battery"]["level"] == 85 + assert result["ip_address"] == "192.168.1.100" + assert result["wifi_ssid"] == "MyNetwork" + assert result["model"] == "Pixel 6" + + async def test_device_offline(self, server): + server.run_shell_args.return_value = fail("device offline") + result = await server.device_info() + assert result["success"] is False + + +class TestDeviceReboot: + @pytest.mark.usefixtures("_dev_mode") + async def test_reboot(self, server, ctx): + ctx.set_elicit("accept", "Yes, reboot now") + server.run_adb.return_value = ok() + result = await server.device_reboot(ctx) + assert result["success"] is True + assert result["mode"] == "normal" + + @pytest.mark.usefixtures("_dev_mode") + async def test_reboot_recovery(self, server, ctx): + ctx.set_elicit("accept", "Yes, reboot now") + server.run_adb.return_value = ok() + result = await server.device_reboot(ctx, mode="recovery") + assert result["mode"] == "recovery" + + @pytest.mark.usefixtures("_dev_mode") + async def test_cancelled(self, server, ctx): + ctx.set_elicit("accept", "Cancel") + result = await server.device_reboot(ctx) + assert result["success"] is False + assert result.get("cancelled") is True + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server, ctx): + result = await server.device_reboot(ctx) + assert result["success"] is False + + +class TestLogcat: + @pytest.mark.usefixtures("_dev_mode") + async def test_capture(self, server): + logline = "01-01 00:00:00.000 I/TAG: message" + server.run_shell_args.return_value = ok(stdout=logline) + result = await server.logcat_capture() + assert result["success"] is True + assert result["output"].startswith("01-01") + + @pytest.mark.usefixtures("_dev_mode") + async def test_with_filter(self, server): + server.run_shell_args.return_value = ok(stdout="filtered output") + result = await server.logcat_capture(filter_spec="MyApp:D *:S") + assert result["filter"] == "MyApp:D *:S" + + @pytest.mark.usefixtures("_dev_mode") + async def test_clear_first(self, server): + server.run_shell_args.side_effect = [ok(), ok(stdout="fresh logs")] + result = await server.logcat_capture(clear_first=True) + assert result["success"] is True + assert server.run_shell_args.call_count == 2 + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server): + result = await server.logcat_capture() + assert result["success"] is False + + @pytest.mark.usefixtures("_dev_mode") + async def test_logcat_clear(self, server): + server.run_shell_args.return_value = ok() + result = await server.logcat_clear() + assert result["success"] is True + assert result["action"] == "logcat_clear" diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..c9d4524 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,120 @@ +"""Tests for files mixin (push, pull, list, delete, exists).""" + +import pytest + +from tests.conftest import fail, ok + + +class TestFilePush: + @pytest.mark.usefixtures("_dev_mode") + async def test_push(self, server, ctx, tmp_path): + local_file = tmp_path / "test.txt" + local_file.write_text("content") + server.run_adb.return_value = ok(stdout="1 file pushed") + result = await server.file_push(ctx, str(local_file), "/sdcard/test.txt") + assert result["success"] is True + assert result["device_path"] == "/sdcard/test.txt" + + @pytest.mark.usefixtures("_dev_mode") + async def test_local_not_found(self, server, ctx): + result = await server.file_push(ctx, "/nonexistent/file.txt", "/sdcard/") + assert result["success"] is False + assert "not found" in result["error"] + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server, ctx): + result = await server.file_push(ctx, "/tmp/f", "/sdcard/f") + assert result["success"] is False + + +class TestFilePull: + @pytest.mark.usefixtures("_dev_mode") + async def test_pull(self, server, ctx, tmp_path): + server.run_adb.return_value = ok(stdout="1 file pulled") + result = await server.file_pull( + ctx, "/sdcard/test.txt", str(tmp_path / "out.txt") + ) + assert result["success"] is True + + @pytest.mark.usefixtures("_dev_mode") + async def test_default_local_path(self, server, ctx): + server.run_adb.return_value = ok() + result = await server.file_pull(ctx, "/sdcard/data.db") + assert "data.db" in result["local_path"] + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server, ctx): + result = await server.file_pull(ctx, "/sdcard/f") + assert result["success"] is False + + +class TestFileList: + @pytest.mark.usefixtures("_dev_mode") + async def test_parse_ls(self, server): + server.run_shell_args.return_value = ok( + stdout=( + "total 16\n" + "drwxr-xr-x 2 root root 4096 2024-01-15 10:30 Documents\n" + "-rw-r--r-- 1 root root 1234 2024-01-15 10:31 test.txt\n" + ) + ) + result = await server.file_list("/sdcard/") + assert result["success"] is True + assert result["count"] == 2 + assert result["files"][0]["name"] == "Documents" + assert result["files"][0]["is_directory"] is True + assert result["files"][1]["name"] == "test.txt" + assert result["files"][1]["is_directory"] is False + + @pytest.mark.usefixtures("_dev_mode") + async def test_failure(self, server): + server.run_shell_args.return_value = fail("No such file") + result = await server.file_list("/nonexistent/") + assert result["success"] is False + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server): + result = await server.file_list() + assert result["success"] is False + + +class TestFileDelete: + @pytest.mark.usefixtures("_dev_mode") + async def test_delete(self, server, ctx): + ctx.set_elicit("accept", "Yes, delete") + server.run_shell_args.return_value = ok() + result = await server.file_delete(ctx, "/sdcard/old.txt") + assert result["success"] is True + assert result["path"] == "/sdcard/old.txt" + + @pytest.mark.usefixtures("_dev_mode") + async def test_cancelled(self, server, ctx): + ctx.set_elicit("accept", "Cancel") + result = await server.file_delete(ctx, "/sdcard/keep.txt") + assert result["success"] is False + assert result.get("cancelled") is True + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server, ctx): + result = await server.file_delete(ctx, "/sdcard/f") + assert result["success"] is False + + +class TestFileExists: + @pytest.mark.usefixtures("_dev_mode") + async def test_exists(self, server): + server.run_shell_args.return_value = ok() + result = await server.file_exists("/sdcard/file.txt") + assert result["success"] is True + assert result["exists"] is True + + @pytest.mark.usefixtures("_dev_mode") + async def test_not_exists(self, server): + server.run_shell_args.return_value = fail() + result = await server.file_exists("/sdcard/missing.txt") + assert result["exists"] is False + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server): + result = await server.file_exists("/sdcard/f") + assert result["success"] is False diff --git a/tests/test_input.py b/tests/test_input.py new file mode 100644 index 0000000..79eafc6 --- /dev/null +++ b/tests/test_input.py @@ -0,0 +1,236 @@ +"""Tests for input mixin (tap, swipe, keys, text, clipboard).""" + +import pytest + +from tests.conftest import fail, ok + + +class TestInputTap: + async def test_tap(self, server): + server.run_shell_args.return_value = ok() + result = await server.input_tap(100, 200) + assert result["success"] is True + assert result["coordinates"] == {"x": 100, "y": 200} + server.run_shell_args.assert_called_once_with( + ["input", "tap", "100", "200"], None + ) + + async def test_tap_with_device(self, server): + server.run_shell_args.return_value = ok() + await server.input_tap(10, 20, device_id="ABC") + server.run_shell_args.assert_called_once_with( + ["input", "tap", "10", "20"], "ABC" + ) + + async def test_tap_failure(self, server): + server.run_shell_args.return_value = fail("no device") + result = await server.input_tap(0, 0) + assert result["success"] is False + assert result["error"] == "no device" + + +class TestInputSwipe: + async def test_swipe(self, server): + server.run_shell_args.return_value = ok() + result = await server.input_swipe(0, 100, 0, 500, duration_ms=500) + assert result["success"] is True + assert result["from"] == {"x": 0, "y": 100} + assert result["to"] == {"x": 0, "y": 500} + assert result["duration_ms"] == 500 + + async def test_swipe_default_duration(self, server): + server.run_shell_args.return_value = ok() + result = await server.input_swipe(0, 0, 100, 100) + assert result["duration_ms"] == 300 + + +class TestInputScroll: + async def test_scroll_down(self, server): + # First call: wm size, second call: the swipe + server.run_shell_args.side_effect = [ + ok("Physical size: 1080x1920"), + ok(), + ] + result = await server.input_scroll_down() + assert result["success"] is True + assert result["action"] == "scroll_down" + + # Verify swipe args: center x, 65% down to 25% down + swipe_call = server.run_shell_args.call_args_list[1] + args = swipe_call[0][0] + assert args[0] == "input" + assert args[1] == "swipe" + assert args[2] == "540" # 1080 // 2 + assert args[3] == "1248" # int(1920 * 0.65) + assert args[5] == "480" # int(1920 * 0.25) + + async def test_scroll_up(self, server): + server.run_shell_args.side_effect = [ + ok("Physical size: 1080x1920"), + ok(), + ] + result = await server.input_scroll_up() + assert result["success"] is True + assert result["action"] == "scroll_up" + + async def test_scroll_fallback_dimensions(self, server): + # wm size fails, should fall back to 1080x1920 + server.run_shell_args.side_effect = [ + fail("error"), + ok(), + ] + result = await server.input_scroll_down() + assert result["success"] is True + + +class TestInputKeys: + async def test_back(self, server): + server.run_shell_args.return_value = ok() + result = await server.input_back() + assert result["action"] == "back" + server.run_shell_args.assert_called_once_with( + ["input", "keyevent", "KEYCODE_BACK"], None + ) + + async def test_home(self, server): + server.run_shell_args.return_value = ok() + result = await server.input_home() + assert result["action"] == "home" + + async def test_recent_apps(self, server): + server.run_shell_args.return_value = ok() + result = await server.input_recent_apps() + assert result["action"] == "recent_apps" + + +class TestInputKey: + async def test_full_keycode(self, server): + server.run_shell_args.return_value = ok() + result = await server.input_key("KEYCODE_ENTER") + assert result["key_code"] == "KEYCODE_ENTER" + + async def test_auto_prefix(self, server): + server.run_shell_args.return_value = ok() + result = await server.input_key("ENTER") + assert result["key_code"] == "KEYCODE_ENTER" + + async def test_strips_dangerous_chars(self, server): + server.run_shell_args.return_value = ok() + result = await server.input_key("KEYCODE_ENTER; rm -rf /") + # Shell metacharacters stripped + assert ";" not in result["key_code"] + assert " " not in result["key_code"] + assert "-" not in result["key_code"] + assert "/" not in result["key_code"] + + async def test_lowercase_normalized(self, server): + server.run_shell_args.return_value = ok() + result = await server.input_key("enter") + assert result["key_code"] == "KEYCODE_ENTER" + + +class TestInputText: + async def test_simple_text(self, server): + server.run_shell_args.return_value = ok() + result = await server.input_text("hello") + assert result["success"] is True + assert result["text"] == "hello" + server.run_shell_args.assert_called_once_with(["input", "text", "hello"], None) + + async def test_spaces_escaped(self, server): + server.run_shell_args.return_value = ok() + await server.input_text("hello world") + server.run_shell_args.assert_called_once_with( + ["input", "text", "hello%sworld"], None + ) + + async def test_rejects_special_chars(self, server): + for char in "'\"\\`$(){}[]|&;<>!~#%^*?": + result = await server.input_text(f"text{char}here") + assert result["success"] is False + assert "clipboard_set" in result["error"] + + async def test_rejects_semicolon_injection(self, server): + result = await server.input_text("hello; rm -rf /") + assert result["success"] is False + + +class TestClipboardSet: + async def test_cmd_clipboard(self, server): + server.run_shell_args.return_value = ok() + result = await server.clipboard_set("test text") + assert result["success"] is True + assert result["action"] == "clipboard_set" + + async def test_cmd_clipboard_not_implemented_falls_back(self, server): + # First call: cmd clipboard returns "no shell command" + # Second call: am broadcast succeeds with result=-1 + server.run_shell_args.side_effect = [ + ok(stderr="No shell command implementation."), + ok(stdout="Broadcast completed: result=-1"), + ] + result = await server.clipboard_set("test") + assert result["success"] is True + assert server.run_shell_args.call_count == 2 + + async def test_no_receiver_reports_failure(self, server): + server.run_shell_args.side_effect = [ + ok(stderr="No shell command implementation."), + ok(stdout="Broadcast completed: result=0"), # No receiver + ] + result = await server.clipboard_set("test") + assert result["success"] is False + assert "no broadcast receiver" in result["error"].lower() + + async def test_paste(self, server): + # First call: cmd clipboard set, second call: paste keyevent + server.run_shell_args.side_effect = [ok(), ok()] + result = await server.clipboard_set("text", paste=True) + assert result["success"] is True + assert result["pasted"] is True + # Verify KEYCODE_PASTE was sent + paste_call = server.run_shell_args.call_args_list[1] + assert "KEYCODE_PASTE" in paste_call[0][0] + + async def test_text_preview_truncated(self, server): + server.run_shell_args.return_value = ok() + long_text = "x" * 200 + result = await server.clipboard_set(long_text) + assert len(result["text"]) < 200 + assert result["text"].endswith("...") + + +class TestInputLongPress: + @pytest.mark.usefixtures("_dev_mode") + async def test_long_press(self, server): + server.run_shell_args.return_value = ok() + result = await server.input_long_press(100, 200, duration_ms=2000) + assert result["success"] is True + assert result["action"] == "long_press" + assert result["duration_ms"] == 2000 + # Long press = swipe from same point to same point + args = server.run_shell_args.call_args[0][0] + assert args[2] == args[4] # x1 == x2 + assert args[3] == args[5] # y1 == y2 + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server): + result = await server.input_long_press(0, 0) + assert result["success"] is False + assert "developer mode" in result["error"].lower() + + +class TestShellCommand: + @pytest.mark.usefixtures("_dev_mode") + async def test_executes(self, server): + server.run_shell.return_value = ok(stdout="output") + result = await server.shell_command("ls /sdcard") + assert result["success"] is True + assert result["stdout"] == "output" + assert result["command"] == "ls /sdcard" + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server): + result = await server.shell_command("ls") + assert result["success"] is False + assert "developer mode" in result["error"].lower() diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..7cd8c6b --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,56 @@ +"""Tests for Pydantic models.""" + +from src.models import CommandResult, DeviceInfo, ScreenshotResult + + +class TestCommandResult: + def test_success(self): + r = CommandResult(success=True, stdout="ok", stderr="", returncode=0) + assert r.success is True + assert r.returncode == 0 + + def test_failure(self): + r = CommandResult(success=False, stdout="", stderr="err", returncode=1) + assert r.success is False + assert r.stderr == "err" + + def test_defaults(self): + r = CommandResult(success=True, returncode=0) + assert r.stdout == "" + assert r.stderr == "" + + def test_model_copy(self): + r = CommandResult(success=True, stdout="ok", stderr="", returncode=0) + r2 = r.model_copy(update={"success": False, "stderr": "changed"}) + assert r2.success is False + assert r2.stderr == "changed" + assert r.success is True # original unchanged + + +class TestDeviceInfo: + def test_basic(self): + d = DeviceInfo(device_id="ABC123", status="device") + assert d.device_id == "ABC123" + assert d.model is None + + def test_full(self): + d = DeviceInfo( + device_id="ABC123", + status="device", + model="Pixel_6", + product="oriole", + ) + assert d.model == "Pixel_6" + dump = d.model_dump() + assert dump["product"] == "oriole" + + +class TestScreenshotResult: + def test_success(self): + r = ScreenshotResult(success=True, local_path="/tmp/shot.png") + assert r.local_path == "/tmp/shot.png" + + def test_failure(self): + r = ScreenshotResult(success=False, error="No device") + assert r.error == "No device" + assert r.local_path is None diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py new file mode 100644 index 0000000..880a9fa --- /dev/null +++ b/tests/test_screenshot.py @@ -0,0 +1,132 @@ +"""Tests for screenshot mixin (capture, screen size, density, record).""" + +import pytest + +from tests.conftest import fail, ok + + +class TestScreenshot: + async def test_capture(self, server, ctx, tmp_path, monkeypatch): + from src.config import get_config + + get_config().default_screenshot_dir = str(tmp_path) + server.run_shell_args.return_value = ok() + server.run_adb.return_value = ok() + result = await server.screenshot(ctx, filename="test.png") + assert result.success is True + assert result.local_path is not None + assert "test.png" in result.local_path + + async def test_capture_failure(self, server, ctx): + server.run_shell_args.return_value = fail("no screen") + result = await server.screenshot(ctx) + assert result.success is False + assert result.error is not None + + async def test_pull_failure(self, server, ctx): + server.run_shell_args.return_value = ok() + server.run_adb.return_value = fail("pull failed") + result = await server.screenshot(ctx) + assert result.success is False + + +class TestScreenSize: + async def test_physical(self, server): + server.run_shell_args.return_value = ok(stdout="Physical size: 1080x1920") + result = await server.screen_size() + assert result["success"] is True + assert result["width"] == 1080 + assert result["height"] == 1920 + + async def test_override(self, server): + server.run_shell_args.return_value = ok( + stdout="Physical size: 1080x1920\nOverride size: 720x1280" + ) + result = await server.screen_size() + assert result["success"] is True + # Should parse the first match + assert result["width"] == 1080 + + async def test_failure(self, server): + server.run_shell_args.return_value = fail("error") + result = await server.screen_size() + assert result["success"] is False + + +class TestScreenDensity: + async def test_density(self, server): + server.run_shell_args.return_value = ok(stdout="Physical density: 420") + result = await server.screen_density() + assert result["success"] is True + assert result["dpi"] == 420 + + async def test_failure(self, server): + server.run_shell_args.return_value = fail("error") + result = await server.screen_density() + assert result["success"] is False + + +class TestScreenOnOff: + async def test_screen_on(self, server): + server.run_shell_args.return_value = ok() + result = await server.screen_on() + assert result["success"] is True + assert result["action"] == "screen_on" + + async def test_screen_off(self, server): + server.run_shell_args.return_value = ok() + result = await server.screen_off() + assert result["action"] == "screen_off" + + +class TestScreenRecord: + @pytest.mark.usefixtures("_dev_mode") + async def test_record(self, server, ctx, tmp_path): + from src.config import get_config + + get_config().default_screenshot_dir = str(tmp_path) + server.run_shell_args.side_effect = [ok(), ok()] # record + rm + server.run_adb.return_value = ok() # pull + result = await server.screen_record( + ctx, + filename="test.mp4", + duration_seconds=5, + ) + assert result["success"] is True + assert result["duration_seconds"] == 5 + + @pytest.mark.usefixtures("_dev_mode") + async def test_duration_capped(self, server, ctx, tmp_path): + from src.config import get_config + + get_config().default_screenshot_dir = str(tmp_path) + server.run_shell_args.side_effect = [ok(), ok()] + server.run_adb.return_value = ok() + result = await server.screen_record(ctx, duration_seconds=999) + assert result["duration_seconds"] == 180 # Capped at 180 + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server, ctx): + result = await server.screen_record(ctx) + assert result["success"] is False + + +class TestScreenSetSize: + @pytest.mark.usefixtures("_dev_mode") + async def test_set(self, server): + server.run_shell_args.return_value = ok() + result = await server.screen_set_size(720, 1280) + assert result["success"] is True + assert result["width"] == 720 + + @pytest.mark.usefixtures("_dev_mode") + async def test_reset(self, server): + server.run_shell_args.return_value = ok() + result = await server.screen_reset_size() + assert result["success"] is True + assert result["action"] == "reset_size" + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server): + result = await server.screen_set_size(720, 1280) + assert result["success"] is False diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..dfd633b --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,36 @@ +"""Tests for server-level tools (config, help resource).""" + + +class TestConfigStatus: + async def test_status(self, server): + result = await server.config_status() + assert "developer_mode" in result + assert "auto_select_single_device" in result + assert "current_device" in result + + async def test_reflects_current_device(self, server): + server.set_current_device("ABC123") + result = await server.config_status() + assert result["current_device"] == "ABC123" + + +class TestConfigSetDeveloperMode: + async def test_enable(self, server): + result = await server.config_set_developer_mode(True) + assert result["success"] is True + assert result["developer_mode"] is True + + async def test_disable(self, server): + result = await server.config_set_developer_mode(False) + assert result["developer_mode"] is False + + +class TestConfigSetScreenshotDir: + async def test_set(self, server): + result = await server.config_set_screenshot_dir("/tmp/shots") + assert result["success"] is True + assert result["screenshot_dir"] == "/tmp/shots" + + async def test_clear(self, server): + result = await server.config_set_screenshot_dir(None) + assert result["screenshot_dir"] is None diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..37b1f10 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,391 @@ +"""Tests for settings mixin (settings, toggles, notifications, clipboard, media).""" + +import pytest + +from src.mixins.settings import _MEDIA_KEYCODES, SettingsMixin +from tests.conftest import fail, ok + + +class TestSettingsGet: + async def test_valid(self, server): + server.run_shell_args.return_value = ok(stdout="1") + result = await server.settings_get("global", "wifi_on") + assert result["success"] is True + assert result["value"] == "1" + assert result["exists"] is True + + async def test_null_value(self, server): + server.run_shell_args.return_value = ok(stdout="null") + result = await server.settings_get("global", "missing_key") + assert result["success"] is True + assert result["value"] is None + assert result["exists"] is False + + async def test_invalid_namespace(self, server): + result = await server.settings_get("invalid", "key") + assert result["success"] is False + assert "Invalid namespace" in result["error"] + + async def test_invalid_key(self, server): + result = await server.settings_get("global", "bad key!") + assert result["success"] is False + assert "Invalid key" in result["error"] + + async def test_all_namespaces_valid(self, server): + server.run_shell_args.return_value = ok(stdout="value") + for ns in ("system", "global", "secure"): + result = await server.settings_get(ns, "test_key") + assert result["success"] is True + + async def test_key_with_dots(self, server): + server.run_shell_args.return_value = ok(stdout="value") + result = await server.settings_get("global", "wifi.scan_always_enabled") + assert result["success"] is True + + +class TestSettingsPut: + @pytest.mark.usefixtures("_dev_mode") + async def test_write_and_verify(self, server, ctx): + server.run_shell_args.side_effect = [ok(), ok(stdout="128")] + result = await server.settings_put(ctx, "system", "screen_brightness", "128") + assert result["success"] is True + assert result["readback"] == "128" + assert result["verified"] is True + + @pytest.mark.usefixtures("_dev_mode") + async def test_invalid_namespace(self, server, ctx): + result = await server.settings_put(ctx, "bad", "key", "val") + assert result["success"] is False + + @pytest.mark.usefixtures("_dev_mode") + async def test_invalid_key(self, server, ctx): + result = await server.settings_put(ctx, "global", "k;ey", "val") + assert result["success"] is False + + @pytest.mark.usefixtures("_dev_mode") + async def test_secure_namespace_elicits(self, server, ctx): + ctx.set_elicit("accept", "Yes, write setting") + server.run_shell_args.side_effect = [ok(), ok(stdout="val")] + result = await server.settings_put(ctx, "secure", "key", "val") + assert result["success"] is True + # Verify elicitation happened + assert any("secure" in msg for _, msg in ctx.messages) + + @pytest.mark.usefixtures("_dev_mode") + async def test_secure_cancelled(self, server, ctx): + ctx.set_elicit("accept", "Cancel") + result = await server.settings_put(ctx, "secure", "key", "val") + assert result["success"] is False + assert result.get("cancelled") is True + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server, ctx): + result = await server.settings_put(ctx, "system", "k", "v") + assert result["success"] is False + + +class TestWifiToggle: + @pytest.mark.usefixtures("_dev_mode") + async def test_enable(self, server): + server.run_shell_args.side_effect = [ok(), ok(stdout="1")] + result = await server.wifi_toggle(True) + assert result["success"] is True + assert result["action"] == "enable" + assert result["verified"] is True + + @pytest.mark.usefixtures("_dev_mode") + async def test_disable(self, server): + server.run_shell_args.side_effect = [ok(), ok(stdout="0")] + result = await server.wifi_toggle(False) + assert result["action"] == "disable" + assert result["verified"] is True + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server): + result = await server.wifi_toggle(True) + assert result["success"] is False + + +class TestBluetoothToggle: + @pytest.mark.usefixtures("_dev_mode") + async def test_enable(self, server): + server.run_shell_args.return_value = ok() + result = await server.bluetooth_toggle(True) + assert result["success"] is True + assert result["action"] == "enable" + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server): + result = await server.bluetooth_toggle(False) + assert result["success"] is False + + +class TestAirplaneModeToggle: + @pytest.mark.usefixtures("_dev_mode") + async def test_enable_usb_device(self, server, ctx): + ctx.set_elicit("accept", "Yes, enable airplane mode") + server.run_shell_args.side_effect = [ok(), ok()] + result = await server.airplane_mode_toggle(ctx, True) + assert result["success"] is True + assert result["airplane_mode"] is True + + @pytest.mark.usefixtures("_dev_mode") + async def test_enable_network_device_warns(self, server, ctx): + server.set_current_device("10.20.0.25:5555") + ctx.set_elicit("accept", "Yes, enable airplane mode") + server.run_shell_args.side_effect = [ok(), ok()] + result = await server.airplane_mode_toggle(ctx, True) + assert result["success"] is True + # Should have warned about network disconnection + warns = [msg for level, msg in ctx.messages if "sever" in msg.lower()] + assert len(warns) > 0 + + @pytest.mark.usefixtures("_dev_mode") + async def test_cancelled(self, server, ctx): + ctx.set_elicit("accept", "Cancel") + result = await server.airplane_mode_toggle(ctx, True) + assert result["success"] is False + assert result.get("cancelled") is True + + @pytest.mark.usefixtures("_dev_mode") + async def test_disable_no_elicitation(self, server, ctx): + server.run_shell_args.side_effect = [ok(), ok()] + result = await server.airplane_mode_toggle(ctx, False) + assert result["success"] is True + # No elicitation for disable + elicits = [m for level, m in ctx.messages if level == "elicit"] + assert len(elicits) == 0 + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server, ctx): + result = await server.airplane_mode_toggle(ctx, True) + assert result["success"] is False + + +class TestScreenBrightness: + @pytest.mark.usefixtures("_dev_mode") + async def test_set(self, server): + server.run_shell_args.side_effect = [ok(), ok()] + result = await server.screen_brightness(128) + assert result["success"] is True + assert result["brightness"] == 128 + assert result["auto_brightness"] is False + + @pytest.mark.usefixtures("_dev_mode") + async def test_out_of_range(self, server): + result = await server.screen_brightness(300) + assert result["success"] is False + assert "0-255" in result["error"] + + @pytest.mark.usefixtures("_dev_mode") + async def test_negative(self, server): + result = await server.screen_brightness(-1) + assert result["success"] is False + + @pytest.mark.usefixtures("_no_dev_mode") + async def test_requires_dev_mode(self, server): + result = await server.screen_brightness(128) + assert result["success"] is False + + +class TestScreenTimeout: + @pytest.mark.usefixtures("_dev_mode") + async def test_set(self, server): + server.run_shell_args.return_value = ok() + result = await server.screen_timeout(30) + assert result["success"] is True + assert result["timeout_seconds"] == 30 + assert result["timeout_ms"] == 30000 + + @pytest.mark.usefixtures("_dev_mode") + async def test_too_large(self, server): + result = await server.screen_timeout(9999) + assert result["success"] is False + assert "1-1800" in result["error"] + + @pytest.mark.usefixtures("_dev_mode") + async def test_zero(self, server): + result = await server.screen_timeout(0) + assert result["success"] is False + + +class TestNotificationList: + async def test_parse_notifications(self, server): + dumpsys_output = """ + NotificationRecord(0x1234 pkg=com.example.app) + extras { + android.title=Test Title + android.text=Test message body + } + postTime=1700000000000 + NotificationRecord(0x5678 pkg=com.other.app) + extras { + android.title=Second + android.text=Another notification + } + postTime=1700000001000 +""" + server.run_shell_args.return_value = ok(stdout=dumpsys_output) + result = await server.notification_list() + assert result["success"] is True + assert result["count"] == 2 + assert result["notifications"][0]["package"] == "com.example.app" + assert result["notifications"][0]["title"] == "Test Title" + assert result["notifications"][0]["text"] == "Test message body" + + async def test_limit(self, server): + # Build output with many notifications + lines = [] + for i in range(10): + lines.append(f" NotificationRecord(0x{i:04x} pkg=com.app{i})") + lines.append(f" android.title=Title {i}") + server.run_shell_args.return_value = ok(stdout="\n".join(lines)) + result = await server.notification_list(limit=3) + assert result["count"] <= 3 + + async def test_empty(self, server): + server.run_shell_args.return_value = ok(stdout="") + result = await server.notification_list() + assert result["success"] is True + assert result["count"] == 0 + + +class TestClipboardGet: + async def test_parses_parcel(self, server): + # Build parcel programmatically with correct encoding + server.run_shell_args.return_value = ok(stdout=_build_parcel("hello world")) + result = await server.clipboard_get() + assert result["success"] is True + assert result["text"] == "hello world" + + async def test_empty_clipboard(self, server): + server.run_shell_args.return_value = ok( + stdout="Result: Parcel(00000000 00000000 '........')" + ) + result = await server.clipboard_get() + # No text/plain marker = not parseable + assert result["success"] is False + + async def test_failure(self, server): + server.run_shell_args.return_value = fail("error") + result = await server.clipboard_get() + assert result["success"] is False + + +def _build_parcel(text: str) -> str: + """Build a fake service call parcel output containing clipboard text. + + Mimics the format of `service call clipboard 4` output with a + ClipData Parcel containing text/plain MIME type and UTF-8 text. + """ + import struct + + parts = [] + # Status word (0 = success) + parts.append(struct.pack(" + + + + + +""" + + +class TestUiDump: + async def test_dump(self, server, ctx): + server.run_shell_args.side_effect = [ + ok(), # uiautomator dump + ok(stdout=SAMPLE_UI_XML), # cat + ok(), # rm cleanup + ] + result = await server.ui_dump(ctx) + assert result["success"] is True + assert result["element_count"] >= 2 # Settings + Wi-Fi at minimum + assert "xml" in result + + async def test_dump_failure(self, server, ctx): + server.run_shell_args.return_value = fail("error") + result = await server.ui_dump(ctx) + assert result["success"] is False + + +class TestParseUiElements: + def test_parse_clickable(self, server): + elements = server._parse_ui_elements(SAMPLE_UI_XML) + texts = [e["text"] for e in elements] + assert "Settings" in texts + assert "Wi-Fi" in texts + + def test_center_coordinates(self, server): + elements = server._parse_ui_elements(SAMPLE_UI_XML) + settings = [e for e in elements if e["text"] == "Settings"][0] + assert settings["center"] == {"x": 100, "y": 125} + + def test_content_desc_included(self, server): + elements = server._parse_ui_elements(SAMPLE_UI_XML) + icon = [e for e in elements if e["content_desc"] == "Settings icon"] + assert len(icon) == 1 + + def test_empty_xml(self, server): + elements = server._parse_ui_elements("") + assert elements == [] + + +class TestUiFindElement: + async def test_find_by_text(self, server): + server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()] + result = await server.ui_find_element(text="Settings") + assert result["success"] is True + assert result["count"] == 1 + assert result["matches"][0]["text"] == "Settings" + + async def test_find_by_resource_id(self, server): + server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()] + result = await server.ui_find_element(resource_id="title") + # Settings and Wi-Fi both have "title" in their resource-id + assert result["count"] >= 2 + + async def test_not_found(self, server): + server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()] + result = await server.ui_find_element(text="Missing") + assert result["success"] is True + assert result["count"] == 0 + + +class TestWaitForText: + async def test_found_immediately(self, server): + server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()] + result = await server.wait_for_text("Settings", timeout_seconds=1) + assert result["success"] is True + assert result["found"] is True + assert result["attempts"] == 1 + + async def test_timeout(self, server): + server.run_shell_args.side_effect = [ + ok(), + ok(stdout=""), + ok(), + ] * 10 + result = await server.wait_for_text( + "Missing", timeout_seconds=0.1, poll_interval=0.05 + ) + assert result["success"] is False + assert result["found"] is False + + +class TestWaitForTextGone: + async def test_already_gone(self, server): + server.run_shell_args.side_effect = [ + ok(), + ok(stdout=""), + ok(), + ] + result = await server.wait_for_text_gone("Missing", timeout_seconds=1) + assert result["success"] is True + assert result["gone"] is True + + +class TestTapText: + async def test_tap_found(self, server): + # find_element calls ui_dump which has 3 calls, then tap has 1 + server.run_shell_args.side_effect = [ + ok(), + ok(stdout=SAMPLE_UI_XML), + ok(), # ui_dump for find + ok(), # tap + ] + result = await server.tap_text("Settings") + assert result["success"] is True + assert result["coordinates"] == {"x": 100, "y": 125} + + async def test_not_found(self, server): + server.run_shell_args.side_effect = [ + ok(), + ok(stdout=SAMPLE_UI_XML), + ok(), # first search by text + ok(), + ok(stdout=SAMPLE_UI_XML), + ok(), # fallback search by content_desc + ] + result = await server.tap_text("NonExistent") + assert result["success"] is False + assert "No element found" in result["error"]