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.
235 lines
7.0 KiB
Python
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
|