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:
parent
e0c05dc72a
commit
fb297f7937
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -102,7 +102,7 @@ class UIMixin(ADBBaseMixin):
|
||||
|
||||
# Regex to find node elements with their attributes
|
||||
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):
|
||||
attrs_str = match.group(1)
|
||||
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
124
tests/conftest.py
Normal file
124
tests/conftest.py
Normal 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
233
tests/test_apps.py
Normal 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
234
tests/test_base.py
Normal 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
78
tests/test_config.py
Normal 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
122
tests/test_connectivity.py
Normal 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
181
tests/test_devices.py
Normal 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
120
tests/test_files.py
Normal 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
236
tests/test_input.py
Normal 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
56
tests/test_models.py
Normal 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
132
tests/test_screenshot.py
Normal 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
36
tests/test_server.py
Normal 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
391
tests/test_settings.py
Normal 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
141
tests/test_ui.py
Normal 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"]
|
||||
Loading…
x
Reference in New Issue
Block a user