mcp-adb/tests/test_base.py
Ryan Malloy fb297f7937 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.
2026-02-11 03:38:37 -07:00

235 lines
7.0 KiB
Python

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