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.
This commit is contained in:
Ryan Malloy 2026-02-11 03:38:37 -07:00
parent e0c05dc72a
commit fb297f7937
17 changed files with 2089 additions and 2 deletions

View File

@ -52,6 +52,9 @@ line-length = 88
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"] select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
[tool.mypy] [tool.mypy]
python_version = "3.11" python_version = "3.11"
strict = true strict = true

View File

@ -513,7 +513,7 @@ class SettingsMixin(ADBBaseMixin):
break break
current = {} current = {}
# Extract package from NotificationRecord line # Extract package from NotificationRecord line
pkg_match = re.search(r"pkg=(\S+)", stripped) pkg_match = re.search(r"pkg=([\w.]+)", stripped)
if pkg_match: if pkg_match:
current["package"] = pkg_match.group(1) current["package"] = pkg_match.group(1)

View File

@ -102,7 +102,7 @@ class UIMixin(ADBBaseMixin):
# Regex to find node elements with their attributes # Regex to find node elements with their attributes
node_pattern = re.compile(r"<node\s+([^>]+?)(?:/>|>)", re.DOTALL) node_pattern = re.compile(r"<node\s+([^>]+?)(?:/>|>)", re.DOTALL)
attr_pattern = re.compile(r'(\w+)="([^"]*)"') attr_pattern = re.compile(r'([\w-]+)="([^"]*)"')
for match in node_pattern.finditer(xml_content): for match in node_pattern.finditer(xml_content):
attrs_str = match.group(1) attrs_str = match.group(1)

0
tests/__init__.py Normal file
View File

124
tests/conftest.py Normal file
View File

@ -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")

233
tests/test_apps.py Normal file
View File

@ -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

234
tests/test_base.py Normal file
View File

@ -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

78
tests/test_config.py Normal file
View File

@ -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

122
tests/test_connectivity.py Normal file
View File

@ -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"]

181
tests/test_devices.py Normal file
View File

@ -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"

120
tests/test_files.py Normal file
View File

@ -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

236
tests/test_input.py Normal file
View File

@ -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()

56
tests/test_models.py Normal file
View File

@ -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

132
tests/test_screenshot.py Normal file
View File

@ -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

36
tests/test_server.py Normal file
View File

@ -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

391
tests/test_settings.py Normal file
View File

@ -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("<I", 0))
# Non-null marker
parts.append(struct.pack("<I", 1))
# ClipDescription: label length = 0 (no label)
parts.append(struct.pack("<I", 0))
# MIME type count = 1
parts.append(struct.pack("<I", 1))
# MIME type "text/plain" in UTF-16LE with length prefix
mime = "text/plain"
mime_utf16 = mime.encode("utf-16-le")
parts.append(struct.pack("<I", len(mime)))
parts.append(mime_utf16)
# Pad to 4-byte boundary
pad = (4 - len(mime_utf16) % 4) % 4
parts.append(b"\x00" * pad)
# Extras (none), timestamps, flags
parts.append(struct.pack("<I", 0xFFFFFFFF)) # no extras
parts.append(struct.pack("<I", 0xFFFFFFFF)) # no extras
parts.append(struct.pack("<I", 0)) # flags
# Item count = 1
parts.append(struct.pack("<I", 1))
# CharSequence type marker
parts.append(struct.pack("<I", 0))
# Text as length-prefixed UTF-8
text_bytes = text.encode("utf-8")
parts.append(struct.pack("<I", len(text_bytes)))
parts.append(text_bytes)
pad = (4 - len(text_bytes) % 4) % 4
parts.append(b"\x00" * pad)
data = b"".join(parts)
# Format as hex words like real parcel output
hex_lines = []
for i in range(0, len(data), 16):
chunk = data[i : i + 16]
words = []
for j in range(0, len(chunk), 4):
word = chunk[j : j + 4].ljust(4, b"\x00")
words.append(int.from_bytes(word, "little"))
hex_str = " ".join(f"{w:08x}" for w in words)
hex_lines.append(f" 0x{i:08x}: {hex_str} '...'")
return "Result: Parcel(\n" + "\n".join(hex_lines) + "\n)"
class TestParseClipboardParcel:
"""Direct tests for the static parcel parser."""
def test_valid_parcel(self):
raw = _build_parcel("test data here")
result = SettingsMixin._parse_clipboard_parcel(raw)
assert result == "test data here"
def test_nonzero_status(self):
raw = "Result: Parcel(00000001 '....')"
result = SettingsMixin._parse_clipboard_parcel(raw)
assert result is None
def test_no_hex_words(self):
result = SettingsMixin._parse_clipboard_parcel("no hex here")
assert result is None
def test_no_mime_marker(self):
raw = "Result: Parcel(00000000 00000001 '........')"
result = SettingsMixin._parse_clipboard_parcel(raw)
assert result is None
def test_long_text(self):
long_text = "The quick brown fox jumps over the lazy dog. " * 10
raw = _build_parcel(long_text)
result = SettingsMixin._parse_clipboard_parcel(raw)
assert result == long_text
class TestMediaControl:
async def test_play(self, server):
server.run_shell_args.return_value = ok()
result = await server.media_control("play")
assert result["success"] is True
assert result["action"] == "play"
assert result["keycode"] == "KEYCODE_MEDIA_PLAY"
async def test_all_actions(self, server):
server.run_shell_args.return_value = ok()
for action, keycode in _MEDIA_KEYCODES.items():
result = await server.media_control(action)
assert result["success"] is True
assert result["keycode"] == keycode
async def test_case_insensitive(self, server):
server.run_shell_args.return_value = ok()
result = await server.media_control("PLAY")
assert result["success"] is True
assert result["action"] == "play"
async def test_unknown_action(self, server):
result = await server.media_control("rewind")
assert result["success"] is False
assert "Unknown action" in result["error"]
assert "play" in result["error"] # Lists available actions
async def test_whitespace_stripped(self, server):
server.run_shell_args.return_value = ok()
result = await server.media_control(" pause ")
assert result["success"] is True
assert result["action"] == "pause"

141
tests/test_ui.py Normal file
View File

@ -0,0 +1,141 @@
"""Tests for UI inspection mixin (dump, find, wait, tap_text)."""
from tests.conftest import fail, ok
SAMPLE_UI_XML = """<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node text="Settings" class="android.widget.TextView"
resource-id="com.android.settings:id/title"
bounds="[0,100][200,150]" clickable="true" focusable="true"
content-desc="" />
<node text="" class="android.widget.ImageView"
resource-id="com.android.settings:id/icon"
bounds="[0,50][48,98]" clickable="false" focusable="false"
content-desc="Settings icon" />
<node text="Wi-Fi" class="android.widget.TextView"
resource-id="com.android.settings:id/title"
bounds="[200,100][400,150]" clickable="true" focusable="false"
content-desc="" />
</hierarchy>
"""
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="<hierarchy></hierarchy>"),
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="<hierarchy></hierarchy>"),
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"]