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]
|
[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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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
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