diff --git a/openspec/changes/android-driver/.openspec.yaml b/openspec/changes/android-driver/.openspec.yaml new file mode 100644 index 0000000..b119b63 --- /dev/null +++ b/openspec/changes/android-driver/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-13 diff --git a/openspec/changes/android-driver/design.md b/openspec/changes/android-driver/design.md new file mode 100644 index 0000000..3381847 --- /dev/null +++ b/openspec/changes/android-driver/design.md @@ -0,0 +1,53 @@ +## Context + +`driver/base.py::Driver` is the driver-independent ABC (connect/disconnect/screenshot/tap/swipe/input/launch/terminate/tree/home/lock/unlock). `driver/wda_driver.py::WDADriver` is the only implementation today: it wraps the Appium Python Client's `XCUITestOptions`, builds a `webdriver.Remote` session against an Appium server, and wraps every operation's exceptions into `core.errors.DriverError`/`DeviceOfflineError`. `driver/registry.py::SUPPORTED_DRIVER_TYPES` maps a `driver_type` string (today only `"wda"`) to a builder that strips out the driver's own config fields from a `connection_info` dict and routes the rest into `extra_capabilities`. + +`openspec/specs/driver-registry/spec.md` already documents this mechanism as driver-type-agnostic: "Adding a driver type requires no changes outside the driver layer." Grepping `api/console.py` and `api/rest.py` confirms neither contains a `"wda"` literal — the API layer resolves `driver_type` purely through the registry, so this change is expected to be additive to `driver/` alone. + +This is exactly the case the project's Hexagonal + DDD governance decision calls out: `core`/`driver`/`device`/`tools` must stay free of LLM/HTTP-framework/MCP dependencies, and every new driver's design must state how it preserves that boundary. `docs/MACOS_IPHONE_SETUP.md` §1 already documents Android as the known, not-yet-registered gap this change closes. + +## Goals / Non-Goals + +**Goals:** +- Add a second real `Driver` implementation (`AndroidDriver`, Appium UiAutomator2) proving the registry extension point generalizes beyond WDA. +- Keep the change additive: no edits to `api/`, `device/`, `tools/`, or `runtime/`. +- Bring the new driver's test coverage above the existing WDA bar (mocked unit tests, not just a hardware-gated integration test). + +**Non-Goals:** +- A full Android SDK/adb/real-device setup guide (`docs/ANDROID_SETUP.md` or equivalent) — deferred to a follow-up change once the driver can be validated against real hardware (user-confirmed scope decision). +- Espresso driver support — UiAutomator2 only, mirroring WDA's XCUITest-only scope. +- Renaming `APEX_WDA_*` env vars — an unrelated, already-documented follow-up item in `docs/MACOS_IPHONE_SETUP.md` §12. +- Any change to `Driver`'s abstract interface — the existing method set is sufficient; Android does not need new capabilities the ABC doesn't already express. + +## Decisions + +- **Registry key is `"uiautomator2"`, not `"android"`.** The existing key `"wda"` names the automation *backend* (WebDriverAgent), not the platform (`"ios"`). For the two supported driver types to stay consistent, Android's key should likewise name its backend — Appium's UiAutomator2 driver — not its platform. Rejected alternative: `"android"`, which would break that symmetry and would also be ambiguous if Espresso support is ever added later (both would be "android"). +- **`AndroidDriverConfig` mirrors `WDADriverConfig` field-for-field where an Android equivalent exists**: `server_url` (same default `http://127.0.0.1:4723` — one Appium server can host sessions for both platforms), `platform_name="Android"`, `automation_name="UiAutomator2"`, `device_name`, `udid` (adb serial, selects among multiple connected devices), `no_reset`, `extra_capabilities`. `wda_local_port` (WDA's per-session port-isolation capability for parallel devices) has a direct UiAutomator2 analog — a system-port capability serving the same purpose — carried over as `system_port`. Rejected alternative: a from-scratch config shape — rejected because the parity makes both drivers predictable to configure from the same `connection_info` dict shape the registry already handles generically. +- **Same error-wrapping pattern as `WDADriver`**: every method's Appium/network exception is caught and re-raised as the existing `DriverError`, with `DeviceOfflineError` reserved for connect failures and pre-connect calls (via the same `_require_client()` guard pattern). Rejected alternative: introducing Android-specific error types — rejected because `core/errors.py`'s existing hierarchy is already driver-agnostic and callers above the driver layer must not need to know which concrete driver raised. +- **Exact Appium UiAutomator2 mobile-command names/parameters for gesture-based methods (`tap`, `swipe`, `home`) are confirmed against the installed `Appium-Python-Client` source/docs at implementation time, not guessed here.** WDA's `tap`/`swipe`/`home` use WDA-specific `mobile:` command names (`mobile: tap`, `mobile: dragFromToForDuration`, `mobile: pressButton`) that do not carry over verbatim to UiAutomator2, which exposes its own gesture command set (e.g. click/drag/swipe gesture commands) with different parameter shapes. Implementation must read the installed driver's actual capability before writing each method body — this is called out as its own task rather than assumed. `screenshot`/`tree`/`input`/`launch`/`terminate`/`lock`/`unlock` map to the same cross-platform Selenium/Appium client methods WDA already uses (`get_screenshot_as_png`, `page_source`, `switch_to.active_element.send_keys`, `activate_app`, `terminate_app`, `lock`, `unlock`) and need no research. +- **`driver-registry` spec gets a new scenario, not a new capability file.** No `wda-driver` capability spec exists today — the registry mechanism is spec'd once, generically, and individual driver behavior is not separately spec'd. Adding an `android-driver` capability would break that precedent for no benefit; instead, `driver-registry`'s existing "Building a factory for a known driver type" requirement gets a second scenario for `driver_type="uiautomator2"`, mirroring the existing `"wda"` scenario, so the spec's example coverage stays symmetric across both real driver types. +- **Test coverage exceeds current WDA parity on purpose.** `WDADriver` today has zero mocked unit tests — only `tests/test_wda_integration.py`, hardware-gated and skipped without `APEX_WDA_*` env vars. For `AndroidDriver`, add mocked unit tests (mock `appium.webdriver.Remote`) covering connect failure → `DeviceOfflineError`, pre-connect calls → `DeviceOfflineError`, and operation exceptions → `DriverError`, in addition to an equivalent hardware-gated `tests/test_android_integration.py`. This is a deliberate quality bar increase, not scope creep — it costs nothing extra in production code and closes a gap the WDA driver has always had. +- **No new setup documentation in this change.** Writing an accurate, detailed Android SDK/adb/real-device guide (parallel to `docs/MACOS_IPHONE_SETUP.md`'s 12 sections) without access to real hardware to validate each step would mean inventing untested instructions — the existing iOS doc reads as having been validated against a real device. `docs/MACOS_IPHONE_SETUP.md` §1 gets only a factual correction (Android driver is now registered); a full setup guide is explicit follow-up work once real-device validation is possible. + +## Risks / Trade-offs + +- [Appium UiAutomator2 mobile-command names for tap/swipe/home are not yet confirmed] → Mitigation: dedicated implementation task to read the installed `Appium-Python-Client` UiAutomator2 driver source/docs before writing these three method bodies; not treated as settled by this design doc. +- [No real Android device or emulator available during this change to exercise `connect()`/`screenshot()` end-to-end] → Mitigation: mocked unit tests cover the error-handling contract; the integration test is env-var gated and simply skips until real hardware is available, exactly mirroring `WDADriver`'s current state — no regression versus today's validation depth. +- [`driver-registry` spec now carries two platform-specific example scenarios under one requirement] → Mitigation: accepted; this is the intended pattern for a third driver type in the future, not a maintenance burden. + +## Migration Plan + +1. Add `driver/android_driver.py` (`AndroidDriverConfig`, `AndroidDriver(Driver)`), verifying exact UiAutomator2 mobile-command names/parameters against the installed Appium client before implementing `tap`/`swipe`/`home`. +2. Add `build_android_driver_factory` and register `SUPPORTED_DRIVER_TYPES["uiautomator2"]` in `driver/registry.py`. +3. Add mocked unit tests for `AndroidDriver` (connect failure, pre-connect calls, per-method exception wrapping). +4. Add `tests/test_android_integration.py`, mirroring `tests/test_wda_integration.py`'s env-var-gated structure. +5. Add the `driver-registry` spec delta scenario for `driver_type="uiautomator2"`. +6. Correct `docs/MACOS_IPHONE_SETUP.md` §1's now-outdated "Android not registered" statement. +7. Run the full non-integration test suite (`uv run --all-packages pytest -m "not integration"`) and confirm no regressions. + +Rollback: everything is additive (one new driver module, one new registry entry, new test files, a one-paragraph doc correction). No data/schema migration. Rollback is deleting the new files and reverting the doc line. + +## Open Questions + +- Exact UiAutomator2 mobile-command names/parameters for `tap`/`swipe`/`home` — resolved during implementation (task-level, not a proposal-level blocker). +- Exact capability key name for the system-port equivalent (`systemPort` or similar) — confirmed against `UiAutomator2Options` during implementation. diff --git a/openspec/changes/android-driver/proposal.md b/openspec/changes/android-driver/proposal.md new file mode 100644 index 0000000..56000c2 --- /dev/null +++ b/openspec/changes/android-driver/proposal.md @@ -0,0 +1,28 @@ +## Why + +`docs/MACOS_IPHONE_SETUP.md` 明确记录了当前的架构缺口:"当前仓库只内置了 `wda` Driver……Android 只是架构上的未来目标,当前 `driver/registry.py` 没有注册 Android Driver,因此仅安装 Android SDK/ADB 还不能让本项目控制 Android 手机。" Hexagonal + DDD 分层治理架构(`driver` 层的 Driver Registry 扩展点模式)从一开始就是为了让新增一个设备平台只需要在 `driver/` 包内添加代码,`driver/wda_driver.py` 已经把这条路径验证了一遍。现在补上 Android 驱动是把这个既定扩展点落地到第二个真实平台,不是新设计。 + +## What Changes + +- 新增 `driver/android_driver.py`:`AndroidDriverConfig`(dataclass)+ `AndroidDriver(Driver)`,通过 Appium Python Client 的 UiAutomator2 driver 连接 Android 设备(真机或模拟器均可,Appium/adb 本身对两者透明),实现 `driver/base.py::Driver` 抽象基类的全部方法(connect/disconnect/screenshot/tap/swipe/input/launch/terminate/tree/home/lock/unlock)。 +- `driver/registry.py`:新增 `build_android_driver_factory`,注册到 `SUPPORTED_DRIVER_TYPES["uiautomator2"]`(key 用自动化后端名而非平台名,与现有 `"wda"` 的命名惯例对称)。 +- 新增 `AndroidDriver` 的 mock 单元测试(mock `appium.webdriver.Remote`),覆盖连接失败、未连接时调用、各操作异常包装为 `DriverError`/`DeviceOfflineError` 的路径——这是比 `WDADriver` 现有测试覆盖更完整的增量,`WDADriver` 目前只有一个真机门控的集成测试。 +- 新增 `tests/test_android_integration.py`:结构镜像 `tests/test_wda_integration.py`,`@pytest.mark.integration` 门控,依赖 `APEX_ANDROID_SERVER_URL`/`APEX_ANDROID_UDID`/`APEX_ANDROID_DEVICE_NAME` 环境变量,无真机环境时 skip。 +- 订正 `docs/MACOS_IPHONE_SETUP.md` 第 1 节中"Android 未注册"的过时表述,改为准确描述 Android 驱动已注册、真机安装手册留待后续变更(不在本次新增)。 +- 不引入新依赖:根 `pyproject.toml` 已声明 `Appium-Python-Client>=5.1.1`,`.venv` 已安装 `appium.options.android.uiautomator2`。 + +## Capabilities + +### New Capabilities +(无。沿用现有惯例:单个驱动实现的具体行为不单独建 spec capability——`driver/wda_driver.py` 落地时也没有为它建一个 `wda-driver` spec,`openspec/specs/` 里只有 `driver-registry` 这一个与驱动相关的 capability,负责 registry 机制本身。为 Android 单独建一个对称的 capability 会与既有惯例不一致。) + +### Modified Capabilities +- `driver-registry`:为"Building a factory for a known driver type"这条既有 Requirement 补一个 `driver_type="uiautomator2"` 的 Scenario,与既有 `driver_type="wda"` 的示例场景对称,证明这条已声明为 driver_type 无关的机制对第二个真实驱动类型同样成立。不新增强约束,只是示例覆盖的完整性。 + +## Impact + +- **新增代码**:`driver/android_driver.py`;`tests/test_android_integration.py`;新增覆盖 `AndroidDriver` 的 mock 单元测试文件。 +- **修改代码**:`driver/registry.py`(新增一个注册项,纯增量,不改动现有 `"wda"` 行为);`docs/MACOS_IPHONE_SETUP.md`(订正第 1 节一句过时表述)。 +- **依赖**:无新增,复用已声明的 `Appium-Python-Client`。 +- **不涉及**:`api/`、`device/`、`tools/`、`runtime/` 任一层——已用 grep 核实这些层当前不含任何 `"wda"` 字面量硬编码,`driver-registry` 既有 Requirement("Adding a driver type requires no changes outside the driver layer")保证新增驱动类型无需改动这些层。 +- **Non-Goals**(本次明确不做):不新增 `docs/ANDROID_SETUP.md` 或同等深度的 Android SDK/adb 真机安装手册(用户已确认,留到驱动落地、有真机可验证后再开后续变更);不支持 Appium 的 Espresso driver(与 WDA 只做 XCUITest、不支持其他 iOS 后端对称);不做 `APEX_WDA_*` → `DEVICE_RUNTIME_WDA_*` 环境变量重命名(文档中记录的独立遗留事项,与本次无关)。 diff --git a/openspec/changes/android-driver/specs/driver-registry/spec.md b/openspec/changes/android-driver/specs/driver-registry/spec.md new file mode 100644 index 0000000..c764db8 --- /dev/null +++ b/openspec/changes/android-driver/specs/driver-registry/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Driver type registry lives in the driver layer +The system SHALL provide a registry, owned by the `driver` package, that maps a `driver_type` string (e.g. `"wda"`, `"uiautomator2"`) to a builder function producing a `DriverFactory` for that type, so that any caller needing to construct a driver for a device does so without importing a concrete driver class directly. + +#### Scenario: Building a factory for a known driver type +- **WHEN** a caller requests a driver factory for `driver_type="wda"` with connection info (e.g. `server_url`, `udid`) +- **THEN** the registry returns a `DriverFactory` that, when invoked, constructs a working `WDADriver` configured with that connection info + +#### Scenario: Building a factory for the Android driver type +- **WHEN** a caller requests a driver factory for `driver_type="uiautomator2"` with connection info (e.g. `server_url`, `udid`) +- **THEN** the registry returns a `DriverFactory` that, when invoked, constructs a working `AndroidDriver` configured with that connection info + +#### Scenario: Building a factory for an unknown driver type +- **WHEN** a caller requests a driver factory for a `driver_type` that is not registered +- **THEN** the registry raises a clear error naming the unsupported `driver_type`, instead of returning `None` or a factory that fails later at connect time diff --git a/openspec/changes/android-driver/tasks.md b/openspec/changes/android-driver/tasks.md new file mode 100644 index 0000000..7ad3645 --- /dev/null +++ b/openspec/changes/android-driver/tasks.md @@ -0,0 +1,36 @@ +## 1. Verify Appium UiAutomator2 command surface + +- [ ] 1.1 Read the installed `Appium-Python-Client` UiAutomator2 driver source/docs (`appium.options.android.uiautomator2`, `appium.webdriver` extensions) to confirm the exact mobile-command names and parameters for: a tap/click gesture at a coordinate, a coordinate-to-coordinate swipe/drag with duration, and a home-button press. Confirm the exact capability key name for per-session port isolation (UiAutomator2's analog to WDA's `wdaLocalPort`). + +## 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`, `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()` using the mobile-command names/parameters confirmed in task 1.1. +- [ ] 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`. + +## 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`. +- [ ] 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. + +## 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. + +## 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.