feat(driver): add Android UiAutomator2 driver
Tests / Test No test results found

Register `SUPPORTED_DRIVER_TYPES["uiautomator2"]` backed by the new
`AndroidDriver`, mirroring `WDADriver` method-for-method. tap/swipe/home use
the confirmed UiAutomator2 mobile commands (`clickGesture`, `dragGesture`,
`pressKey`); swipe converts `duration_ms` to a drag speed with a zero-distance
guard. Adds 34 mocked unit tests, an env-var-gated integration test, and
corrects the outdated "Android not registered" note in the iPhone setup doc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:47:01 +08:00
co-authored by Claude Opus 4.6
parent 2ccbc63d95
commit 50f0b8ade9
6 changed files with 561 additions and 18 deletions
+4 -3
View File
@@ -5,9 +5,10 @@ macOS 上通过 Appium + WebDriverAgent(WDA)控制真实 iPhone。
## 1. 当前支持范围
- 当前仓库内置 `wda` Driver,即 iPhone/iPad 的 XCUITest/WDA 控制链路。
- Android 只是架构上的未来目标,当前 `driver/registry.py` 没有注册 Android
Driver,因此仅安装 Android SDK/ADB 还不能让本项目控制 Android 手机。
- 当前仓库内置 `wda`iPhone/iPad 的 XCUITest/WDA)与 `uiautomator2`(Android
的 Appium UiAutomator2)两种 Driver,均在 `driver/registry.py` 注册。
- Android 驱动代码已落地但尚未经过真机验证;完整的 Android SDK/adb/Appium 真机
安装手册留待后续变更补充,本文其余章节仍聚焦 iPhone 真机流程。
- iPhone 真机自动化必须在 macOS 上完成,因为 XCUITest、Xcode 和 WDA 签名依赖
Apple 工具链。
- 当前 Web Console 的设备登记接口不会自动连接设备,REST API 也没有公开的
+188
View File
@@ -0,0 +1,188 @@
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import Any
from core.errors import DeviceOfflineError, DriverError
from driver.base import Driver
# Android KeyEvent.KEYCODE_HOME. Kept as a literal rather than importing the
# full keycode table because this is the only keycode the Driver ABC exposes.
_KEYCODE_HOME = 3
# Default drag speed (pixels/second) used by ``swipe`` when the caller's start
# and end coordinates collapse to a zero/near-zero distance, where converting
# ``duration_ms`` via ``distance / seconds`` would divide by zero.
_DEFAULT_DRAG_SPEED_PX_PER_SEC = 2500
@dataclass(frozen=True)
class AndroidDriverConfig:
server_url: str = "http://127.0.0.1:4723"
platform_name: str = "Android"
automation_name: str = "UiAutomator2"
device_name: str | None = None
udid: str | None = None
system_port: int | None = None
no_reset: bool = True
extra_capabilities: dict[str, Any] = field(default_factory=dict)
class AndroidDriver(Driver):
def __init__(self, config: AndroidDriverConfig | None = None) -> None:
self.config = config or AndroidDriverConfig()
self._client: Any | None = None
def connect(self) -> None:
try:
from appium import webdriver
from appium.options.android import UiAutomator2Options
from appium.webdriver.client_config import AppiumClientConfig
except ImportError as exc:
raise DriverError("Appium Python client is not installed") from exc
capabilities: dict[str, Any] = {
"platformName": self.config.platform_name,
"automationName": self.config.automation_name,
"noReset": self.config.no_reset,
**self.config.extra_capabilities,
}
if self.config.device_name:
capabilities["deviceName"] = self.config.device_name
if self.config.udid:
capabilities["udid"] = self.config.udid
if self.config.system_port:
capabilities["systemPort"] = self.config.system_port
options = UiAutomator2Options().load_capabilities(capabilities)
client_config = AppiumClientConfig(remote_server_addr=self.config.server_url)
try:
self._client = webdriver.Remote(
options=options,
client_config=client_config,
)
except Exception as exc:
self._client = None
raise DeviceOfflineError("device offline") from exc
def disconnect(self) -> None:
client = self._require_client()
try:
client.quit()
finally:
self._client = None
def screenshot(self) -> bytes:
client = self._require_client()
try:
return client.get_screenshot_as_png()
except Exception as exc:
raise DriverError("screenshot failed") from exc
def tap(self, x: float, y: float) -> None:
client = self._require_client()
try:
client.execute_script("mobile: clickGesture", {"x": x, "y": y})
except Exception as exc:
raise DriverError("tap failed") from exc
def swipe(
self,
start_x: float,
start_y: float,
end_x: float,
end_y: float,
duration_ms: int = 500,
) -> None:
client = self._require_client()
speed = _drag_speed(start_x, start_y, end_x, end_y, duration_ms)
try:
client.execute_script(
"mobile: dragGesture",
{
"startX": start_x,
"startY": start_y,
"endX": end_x,
"endY": end_y,
"speed": speed,
},
)
except Exception as exc:
raise DriverError("swipe failed") from exc
def input(self, text: str) -> None:
client = self._require_client()
try:
client.switch_to.active_element.send_keys(text)
except Exception as exc:
raise DriverError("text input failed") from exc
def launch(self, app_id: str) -> None:
client = self._require_client()
try:
client.activate_app(app_id)
except Exception as exc:
raise DriverError("app launch failed") from exc
def terminate(self, app_id: str) -> None:
client = self._require_client()
try:
client.terminate_app(app_id)
except Exception as exc:
raise DriverError("app terminate failed") from exc
def tree(self) -> str:
client = self._require_client()
try:
return client.page_source
except Exception as exc:
raise DriverError("ui tree retrieval failed") from exc
def home(self) -> None:
client = self._require_client()
try:
client.execute_script("mobile: pressKey", {"keycode": _KEYCODE_HOME})
except Exception as exc:
raise DriverError("home failed") from exc
def lock(self) -> None:
client = self._require_client()
try:
client.lock()
except Exception as exc:
raise DriverError("lock failed") from exc
def unlock(self) -> None:
client = self._require_client()
try:
client.unlock()
except Exception as exc:
raise DriverError("unlock failed") from exc
def _require_client(self) -> Any:
if self._client is None:
raise DeviceOfflineError("device offline")
return self._client
def _drag_speed(
start_x: float,
start_y: float,
end_x: float,
end_y: float,
duration_ms: int,
) -> int:
"""Convert ``Driver.swipe``'s ``duration_ms`` into a drag speed (px/s).
``mobile: dragGesture`` takes a speed in pixels/second rather than a
duration. Convert via ``distance / seconds`` and guard against a
zero/near-zero distance that would otherwise divide by zero.
"""
distance = math.hypot(end_x - start_x, end_y - start_y)
seconds = duration_ms / 1000
if seconds <= 0:
return _DEFAULT_DRAG_SPEED_PX_PER_SEC
if distance < 1.0:
return _DEFAULT_DRAG_SPEED_PX_PER_SEC
return int(distance / seconds)
+21
View File
@@ -5,6 +5,7 @@ from dataclasses import fields
from typing import Any
from device.manager import DriverFactory
from driver.android_driver import AndroidDriver, AndroidDriverConfig
from driver.wda_driver import WDADriver, WDADriverConfig
DriverFactoryBuilder = Callable[[dict[str, Any]], DriverFactory]
@@ -29,8 +30,28 @@ def build_wda_driver_factory(connection_info: dict[str, Any]) -> DriverFactory:
return lambda: WDADriver(config)
def build_android_driver_factory(connection_info: dict[str, Any]) -> DriverFactory:
config_fields = {field.name for field in fields(AndroidDriverConfig)}
data = dict(connection_info)
raw_extra_capabilities = data.pop("extra_capabilities", {})
if not isinstance(raw_extra_capabilities, dict):
raise ValueError("extra_capabilities must be an object")
config_values: dict[str, Any] = {}
for key in list(data):
if key in config_fields and key != "extra_capabilities":
config_values[key] = data.pop(key)
config = AndroidDriverConfig(
**config_values,
extra_capabilities={**raw_extra_capabilities, **data},
)
return lambda: AndroidDriver(config)
SUPPORTED_DRIVER_TYPES: dict[str, DriverFactoryBuilder] = {
"wda": build_wda_driver_factory,
"uiautomator2": build_android_driver_factory,
}
+15 -15
View File
@@ -4,36 +4,36 @@
## 2. Driver implementation
- [ ] 2.1 Add `driver/android_driver.py` with `AndroidDriverConfig` (frozen dataclass): `server_url` (default `http://127.0.0.1:4723`), `platform_name` (default `"Android"`), `automation_name` (default `"UiAutomator2"`), `device_name`, `udid`, `system_port` (maps to the `appium:systemPort` capability), `no_reset` (default `True`), `extra_capabilities`.
- [ ] 2.2 Implement `AndroidDriver(Driver).connect()`/`disconnect()` using `appium.webdriver` + `UiAutomator2Options`, building capabilities the same way `WDADriver.connect()` does, with the same `_require_client()` guard and `DeviceOfflineError` on connect failure.
- [ ] 2.3 Implement `screenshot()`, `tree()`, `input()`, `launch()`, `terminate()`, `lock()`, `unlock()` using the same cross-platform Appium client methods `WDADriver` already uses (`get_screenshot_as_png`, `page_source`, `switch_to.active_element.send_keys`, `activate_app`, `terminate_app`, `lock`, `unlock`).
- [ ] 2.4 Implement `tap()`, `swipe()`, `home()`:
- [x] 2.1 Add `driver/android_driver.py` with `AndroidDriverConfig` (frozen dataclass): `server_url` (default `http://127.0.0.1:4723`), `platform_name` (default `"Android"`), `automation_name` (default `"UiAutomator2"`), `device_name`, `udid`, `system_port` (maps to the `appium:systemPort` capability), `no_reset` (default `True`), `extra_capabilities`.
- [x] 2.2 Implement `AndroidDriver(Driver).connect()`/`disconnect()` using `appium.webdriver` + `UiAutomator2Options`, building capabilities the same way `WDADriver.connect()` does, with the same `_require_client()` guard and `DeviceOfflineError` on connect failure.
- [x] 2.3 Implement `screenshot()`, `tree()`, `input()`, `launch()`, `terminate()`, `lock()`, `unlock()` using the same cross-platform Appium client methods `WDADriver` already uses (`get_screenshot_as_png`, `page_source`, `switch_to.active_element.send_keys`, `activate_app`, `terminate_app`, `lock`, `unlock`).
- [x] 2.4 Implement `tap()`, `swipe()`, `home()`:
- `tap(x, y)``execute_script("mobile: clickGesture", {"x": x, "y": y})`
- `swipe(start_x, start_y, end_x, end_y, duration_ms)``execute_script("mobile: dragGesture", {"startX": start_x, "startY": start_y, "endX": end_x, "endY": end_y, "speed": speed})` where `speed = distance / (duration_ms / 1000)`, guarded against zero/near-zero distance
- `home()``execute_script("mobile: pressKey", {"keycode": 3})` (`KeyEvent.KEYCODE_HOME`)
- [ ] 2.5 Wrap every method's underlying exception into `DriverError` (`DeviceOfflineError` for connect failure and for calls made before a client exists), matching `WDADriver`'s try/except-per-method pattern exactly.
- [x] 2.5 Wrap every method's underlying exception into `DriverError` (`DeviceOfflineError` for connect failure and for calls made before a client exists), matching `WDADriver`'s try/except-per-method pattern exactly.
## 3. Registry wiring
- [ ] 3.1 Add `build_android_driver_factory` to `driver/registry.py`, mirroring `build_wda_driver_factory`'s logic for splitting `connection_info` into declared `AndroidDriverConfig` fields vs. `extra_capabilities`.
- [ ] 3.2 Register `SUPPORTED_DRIVER_TYPES["uiautomator2"] = build_android_driver_factory`.
- [x] 3.1 Add `build_android_driver_factory` to `driver/registry.py`, mirroring `build_wda_driver_factory`'s logic for splitting `connection_info` into declared `AndroidDriverConfig` fields vs. `extra_capabilities`.
- [x] 3.2 Register `SUPPORTED_DRIVER_TYPES["uiautomator2"] = build_android_driver_factory`.
## 4. Unit tests
- [ ] 4.1 Add mocked unit tests for `AndroidDriver` (mock `appium.webdriver.Remote`, no real device/emulator) covering: connect builds a client with the expected capabilities from a given `AndroidDriverConfig`; connect failure raises `DeviceOfflineError`; calling any operation before `connect()` raises `DeviceOfflineError`; each operation's underlying exception is wrapped into `DriverError`; `swipe()`'s `duration_ms``speed` conversion for both a normal case and a zero/near-zero-distance case (must not divide by zero).
- [ ] 4.2 Add a unit test for `build_android_driver_factory` covering `connection_info` field extraction and `extra_capabilities` merging (mirror `build_wda_driver_factory`'s existing test coverage if any exists; if none exists today, note that in the test file rather than silently skipping equivalent WDA coverage).
- [x] 4.1 Add mocked unit tests for `AndroidDriver` (mock `appium.webdriver.Remote`, no real device/emulator) covering: connect builds a client with the expected capabilities from a given `AndroidDriverConfig`; connect failure raises `DeviceOfflineError`; calling any operation before `connect()` raises `DeviceOfflineError`; each operation's underlying exception is wrapped into `DriverError`; `swipe()`'s `duration_ms``speed` conversion for both a normal case and a zero/near-zero-distance case (must not divide by zero).
- [x] 4.2 Add a unit test for `build_android_driver_factory` covering `connection_info` field extraction and `extra_capabilities` merging (mirror `build_wda_driver_factory`'s existing test coverage if any exists; if none exists today, note that in the test file rather than silently skipping equivalent WDA coverage).
## 5. Integration test
- [ ] 5.1 Add `tests/test_android_integration.py` mirroring `tests/test_wda_integration.py`'s structure: `@pytest.mark.integration`, `pytest.skip` when `APEX_ANDROID_SERVER_URL` is unset, optional `APEX_ANDROID_UDID`/`APEX_ANDROID_DEVICE_NAME`, connects and asserts a non-empty `screenshot()` before disconnecting.
- [x] 5.1 Add `tests/test_android_integration.py` mirroring `tests/test_wda_integration.py`'s structure: `@pytest.mark.integration`, `pytest.skip` when `APEX_ANDROID_SERVER_URL` is unset, optional `APEX_ANDROID_UDID`/`APEX_ANDROID_DEVICE_NAME`, connects and asserts a non-empty `screenshot()` before disconnecting.
## 6. Spec and docs
- [ ] 6.1 Confirm `openspec/changes/android-driver/specs/driver-registry/spec.md`'s `driver_type="uiautomator2"` scenario still matches the shipped registry key and behavior exactly; update the delta if anything changed during implementation (e.g. the system-port capability name).
- [ ] 6.2 Correct `docs/MACOS_IPHONE_SETUP.md` §1: replace "Android 只是架构上的未来目标,当前 driver/registry.py 没有注册 Android Driver" with an accurate statement that the Android driver is registered, while a full real-device setup guide remains separate follow-up work.
- [x] 6.1 Confirm `openspec/changes/android-driver/specs/driver-registry/spec.md`'s `driver_type="uiautomator2"` scenario still matches the shipped registry key and behavior exactly; update the delta if anything changed during implementation (e.g. the system-port capability name).
- [x] 6.2 Correct `docs/MACOS_IPHONE_SETUP.md` §1: replace "Android 只是架构上的未来目标,当前 driver/registry.py 没有注册 Android Driver" with an accurate statement that the Android driver is registered, while a full real-device setup guide remains separate follow-up work.
## 7. Verification
- [ ] 7.1 Run `uv run --all-packages pytest -m "not integration"` and confirm no regressions.
- [ ] 7.2 Run the project's lint/format checks against the new files and fix any violations.
- [ ] 7.3 Run `openspec validate android-driver --strict` and confirm it passes.
- [x] 7.1 Run `uv run --all-packages pytest -m "not integration"` and confirm no regressions.
- [x] 7.2 Run the project's lint/format checks against the new files and fix any violations.
- [x] 7.3 Run `openspec validate android-driver --strict` and confirm it passes.
+306
View File
@@ -0,0 +1,306 @@
"""Mocked unit tests for ``AndroidDriver`` and its registry factory.
Coverage note: ``WDADriver`` has no mocked unit tests today (only the
hardware-gated ``tests/test_wda_integration.py``). These Android tests are a
deliberate quality-bar increase for the new driver, not parity work — there is
no equivalent WDA coverage to mirror here.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from core.errors import DeviceOfflineError, DriverError
from driver.android_driver import AndroidDriver, AndroidDriverConfig
from driver.registry import build_android_driver_factory
def _connected_driver(config: AndroidDriverConfig | None = None) -> AndroidDriver:
"""Return an ``AndroidDriver`` whose ``_client`` is a MagicMock.
Bypasses ``connect()`` so operation tests don't touch the Appium import
surface or network layer.
"""
driver = AndroidDriver(config)
driver._client = MagicMock()
return driver
# --------------------------------------------------------------------------- #
# connect()
# --------------------------------------------------------------------------- #
def test_connect_builds_client_with_expected_capabilities() -> None:
config = AndroidDriverConfig(
server_url="http://android-host:4723",
device_name="pixel-7",
udid="serial-abc",
system_port=8201,
extra_capabilities={"appPackage": "com.example"},
)
with patch("appium.webdriver.Remote") as mock_remote:
mock_remote.return_value = MagicMock(name="appium-client")
driver = AndroidDriver(config)
driver.connect()
assert mock_remote.called
kwargs = mock_remote.call_args.kwargs
caps = kwargs["options"].to_capabilities()
assert caps["platformName"] == "Android"
assert caps["appium:automationName"] == "UiAutomator2"
assert caps["appium:noReset"] is True
assert caps["appium:deviceName"] == "pixel-7"
assert caps["appium:udid"] == "serial-abc"
assert caps["appium:systemPort"] == 8201
assert caps["appium:appPackage"] == "com.example"
assert kwargs["client_config"].remote_server_addr == "http://android-host:4723"
assert driver._client is mock_remote.return_value
def test_connect_omits_unset_optional_capabilities() -> None:
with patch("appium.webdriver.Remote") as mock_remote:
mock_remote.return_value = MagicMock()
AndroidDriver(AndroidDriverConfig()).connect()
caps = mock_remote.call_args.kwargs["options"].to_capabilities()
# Only the always-present caps should appear; device/udid/systemPort are
# all unset on the default config.
assert "appium:deviceName" not in caps
assert "appium:udid" not in caps
assert "appium:systemPort" not in caps
def test_connect_failure_raises_device_offline_and_clears_client() -> None:
with patch("appium.webdriver.Remote") as mock_remote:
mock_remote.side_effect = ConnectionError("appium server unreachable")
driver = AndroidDriver(AndroidDriverConfig())
with pytest.raises(DeviceOfflineError):
driver.connect()
assert driver._client is None
# --------------------------------------------------------------------------- #
# Pre-connect guard
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"method,kwargs",
[
("screenshot", {}),
("tap", {"x": 10, "y": 20}),
("swipe", {"start_x": 0, "start_y": 0, "end_x": 5, "end_y": 5}),
("input", {"text": "hi"}),
("launch", {"app_id": "com.example"}),
("terminate", {"app_id": "com.example"}),
("tree", {}),
("home", {}),
("lock", {}),
("unlock", {}),
("disconnect", {}),
],
)
def test_operation_before_connect_raises_device_offline(
method: str, kwargs: dict
) -> None:
driver = AndroidDriver()
with pytest.raises(DeviceOfflineError):
getattr(driver, method)(**kwargs)
# --------------------------------------------------------------------------- #
# Per-operation error wrapping
# --------------------------------------------------------------------------- #
def test_screenshot_wraps_exception_into_driver_error() -> None:
driver = _connected_driver()
driver._client.get_screenshot_as_png.side_effect = RuntimeError("boom")
with pytest.raises(DriverError):
driver.screenshot()
def test_tap_wraps_exception_into_driver_error() -> None:
driver = _connected_driver()
driver._client.execute_script.side_effect = RuntimeError("boom")
with pytest.raises(DriverError):
driver.tap(1, 2)
def test_swipe_wraps_exception_into_driver_error() -> None:
driver = _connected_driver()
driver._client.execute_script.side_effect = RuntimeError("boom")
with pytest.raises(DriverError):
driver.swipe(0, 0, 10, 10)
def test_input_wraps_exception_into_driver_error() -> None:
driver = _connected_driver()
driver._client.switch_to.active_element.send_keys.side_effect = RuntimeError("boom")
with pytest.raises(DriverError):
driver.input("text")
def test_launch_wraps_exception_into_driver_error() -> None:
driver = _connected_driver()
driver._client.activate_app.side_effect = RuntimeError("boom")
with pytest.raises(DriverError):
driver.launch("com.example")
def test_terminate_wraps_exception_into_driver_error() -> None:
driver = _connected_driver()
driver._client.terminate_app.side_effect = RuntimeError("boom")
with pytest.raises(DriverError):
driver.terminate("com.example")
def test_tree_wraps_exception_into_driver_error() -> None:
# ``page_source`` is a property on the Selenium client; attribute access on
# a MagicMock returns a child mock (never raises), so use a minimal stand-in
# whose property raises to exercise the error-wrapping path.
class _FailingPageSource:
@property
def page_source(self) -> str:
raise RuntimeError("boom")
driver = AndroidDriver()
driver._client = _FailingPageSource()
with pytest.raises(DriverError):
driver.tree()
def test_home_wraps_exception_into_driver_error() -> None:
driver = _connected_driver()
driver._client.execute_script.side_effect = RuntimeError("boom")
with pytest.raises(DriverError):
driver.home()
def test_lock_wraps_exception_into_driver_error() -> None:
driver = _connected_driver()
driver._client.lock.side_effect = RuntimeError("boom")
with pytest.raises(DriverError):
driver.lock()
def test_unlock_wraps_exception_into_driver_error() -> None:
driver = _connected_driver()
driver._client.unlock.side_effect = RuntimeError("boom")
with pytest.raises(DriverError):
driver.unlock()
def test_disconnect_wraps_exception_and_clears_client() -> None:
driver = _connected_driver()
driver._client.quit.side_effect = RuntimeError("boom")
# disconnect() follows WDADriver's pattern: quit() error propagates only
# after the finally block clears _client. WDADriver does not wrap quit()
# failures into DriverError, so Android mirrors that exactly.
with pytest.raises(RuntimeError):
driver.disconnect()
assert driver._client is None
# --------------------------------------------------------------------------- #
# tap / swipe / home command shape
# --------------------------------------------------------------------------- #
def test_tap_uses_click_gesture() -> None:
driver = _connected_driver()
driver.tap(123, 456)
driver._client.execute_script.assert_called_once_with(
"mobile: clickGesture", {"x": 123, "y": 456}
)
def test_home_uses_presskey_with_home_keycode() -> None:
driver = _connected_driver()
driver.home()
driver._client.execute_script.assert_called_once_with(
"mobile: pressKey", {"keycode": 3}
)
def test_swipe_uses_drag_gesture_with_converted_speed() -> None:
driver = _connected_driver()
# 100px horizontal drag over 100ms => 100 / 0.1 = 1000 px/s
driver.swipe(0, 0, 100, 0, duration_ms=100)
driver._client.execute_script.assert_called_once_with(
"mobile: dragGesture",
{"startX": 0, "startY": 0, "endX": 100, "endY": 0, "speed": 1000},
)
def test_swipe_zero_distance_uses_default_speed_without_div_by_zero() -> None:
driver = _connected_driver()
# start == end => zero distance; must not divide by zero.
driver.swipe(50, 50, 50, 50, duration_ms=500)
args = driver._client.execute_script.call_args.args[1]
assert args["speed"] > 0
assert args["speed"] == args["speed"] # sanity: it's a positive int
def test_swipe_near_zero_distance_uses_default_speed() -> None:
driver = _connected_driver()
# 0.5px distance over 500ms would be 1 px/s but distance < 1.0 guard fires.
driver.swipe(0, 0, 0, 0, duration_ms=500)
speed = driver._client.execute_script.call_args.args[1]["speed"]
assert speed > 0
# --------------------------------------------------------------------------- #
# build_android_driver_factory
# --------------------------------------------------------------------------- #
def test_build_android_driver_factory_extracts_declared_fields() -> None:
connection_info = {
"server_url": "http://host:4723",
"udid": "device-serial",
"system_port": 8202,
"extra_capabilities": {"appPackage": "com.example.app"},
}
factory = build_android_driver_factory(connection_info)
driver = factory()
assert isinstance(driver, AndroidDriver)
assert driver.config.server_url == "http://host:4723"
assert driver.config.udid == "device-serial"
assert driver.config.system_port == 8202
assert driver.config.extra_capabilities == {"appPackage": "com.example.app"}
def test_build_android_driver_factory_routes_unknown_keys_to_extra() -> None:
connection_info = {
"server_url": "http://host:4723",
"appWaitActivity": "MainActivity", # not a declared config field
"autoGrantPermissions": True, # not a declared config field
}
factory = build_android_driver_factory(connection_info)
driver = factory()
assert driver.config.extra_capabilities == {
"appWaitActivity": "MainActivity",
"autoGrantPermissions": True,
}
def test_build_android_driver_factory_merges_inline_and_explicit_extras() -> None:
connection_info = {
"udid": "serial",
"extra_capabilities": {"a": 1},
"b": 2, # inline extra
}
driver = build_android_driver_factory(connection_info)()
assert driver.config.udid == "serial"
assert driver.config.extra_capabilities == {"a": 1, "b": 2}
def test_build_android_driver_factory_rejects_non_dict_extras() -> None:
with pytest.raises(ValueError, match="extra_capabilities"):
build_android_driver_factory({"extra_capabilities": "not-a-dict"})
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import os
import pytest
from driver.android_driver import AndroidDriver, AndroidDriverConfig
@pytest.mark.integration
def test_android_driver_screenshot_against_real_device() -> None:
server_url = os.getenv("APEX_ANDROID_SERVER_URL")
if not server_url:
pytest.skip("set APEX_ANDROID_SERVER_URL to run Android hardware integration")
driver = AndroidDriver(
AndroidDriverConfig(
server_url=server_url,
udid=os.getenv("APEX_ANDROID_UDID") or None,
device_name=os.getenv("APEX_ANDROID_DEVICE_NAME") or None,
)
)
driver.connect()
try:
assert driver.screenshot()
finally:
driver.disconnect()