diff --git a/docs/MACOS_IPHONE_SETUP.md b/docs/MACOS_IPHONE_SETUP.md index 6e3f58f..eb96e8c 100644 --- a/docs/MACOS_IPHONE_SETUP.md +++ b/docs/MACOS_IPHONE_SETUP.md @@ -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 也没有公开的 diff --git a/driver/android_driver.py b/driver/android_driver.py new file mode 100644 index 0000000..35ccbf7 --- /dev/null +++ b/driver/android_driver.py @@ -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) diff --git a/driver/registry.py b/driver/registry.py index 8237c60..67c1337 100644 --- a/driver/registry.py +++ b/driver/registry.py @@ -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, } diff --git a/openspec/changes/android-driver/tasks.md b/openspec/changes/android-driver/tasks.md index 8e8a0db..956b181 100644 --- a/openspec/changes/android-driver/tasks.md +++ b/openspec/changes/android-driver/tasks.md @@ -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. diff --git a/tests/test_android_driver.py b/tests/test_android_driver.py new file mode 100644 index 0000000..11be733 --- /dev/null +++ b/tests/test_android_driver.py @@ -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"}) diff --git a/tests/test_android_integration.py b/tests/test_android_integration.py new file mode 100644 index 0000000..5435e17 --- /dev/null +++ b/tests/test_android_integration.py @@ -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()