Compare commits

...
2 Commits
Author SHA1 Message Date
q792602257andClaude Opus 4.6 50f0b8ade9 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>
2026-07-13 14:47:01 +08:00
q792602257andClaude Opus 4.6 2ccbc63d95 feat(cloud-api): bake cloud-console SPA into the image and serve at /console
Multi-stage Dockerfile: stage 1 (node:20-bookworm-slim) builds cloud-console
with vite base "/console/"; stage 2 (uv) copies dist/ to /app/console-static.
Cloud API mounts the SPA at /console via SpaStaticFiles (StaticFiles subclass
that falls back to index.html for deep-link refreshes) when the new
CLOUD_CONSOLE_STATIC_DIR env is set, and 307-redirects / to /console/. Static
files bypass bearer auth (the SPA shell is public; tokens are still required
for /v1/*). Compose enables the mount by default; local dev still uses
npm run dev + CLOUD_CONSOLE_CORS_ORIGINS.

Jenkinsfile passes mirror overrides (NODE_IMAGE, NPM_REGISTRY, UV_IMAGE,
APT_MIRROR, UV_INDEX_URL) as --build-arg, defaulting to CN mirrors
(registry.jerryyan.net, registry.npmmirror.com, registry-ghcr.jerryyan.top,
mirrors.aliyun.com) so CN builds don't time out; Dockerfile ARGs default to
official upstreams so `docker build .` still works anywhere.

Backend suite: 443 passed (-m "not integration"); cloud-console typecheck
and production build succeed with the new base path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-13 14:40:52 +08:00
16 changed files with 818 additions and 22 deletions
+2
View File
@@ -8,3 +8,5 @@ __pycache__
*.sqlite3
tasks
console/node_modules
cloud-console/node_modules
cloud-console/dist
+47 -2
View File
@@ -1,4 +1,33 @@
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
# syntax=docker/dockerfile:1
#
# Build args let CI inject mirrors for faster builds in CN networks; the
# defaults keep the Dockerfile portable so anyone can `docker build .`
# without extra configuration.
#
# NODE_IMAGE – stage 1 base (Docker Hub library/node)
# NPM_REGISTRY – npm registry for `npm ci`
# UV_IMAGE – stage 2 base (ghcr.io/astral-sh/uv)
# APT_MIRROR – Debian apt mirror host (e.g. mirrors.aliyun.com); empty = official
# UV_INDEX_URL – PyPI index URL passed through to `uv sync`; empty = official
# Stage 1: build the cloud-console Vue 3 SPA.
# NOTE: do not set NODE_ENV=production here — vue-tsc and typescript are
# devDependencies required by `npm run build`.
ARG NODE_IMAGE=node:20-bookworm-slim
ARG NPM_REGISTRY=https://registry.npmjs.org
FROM ${NODE_IMAGE} AS frontend
# Re-declare inside the stage so --build-arg values (or the global default)
# are visible to RUN. Without this, ARGs declared before FROM are inaccessible.
ARG NPM_REGISTRY
WORKDIR /app
COPY cloud-console/package.json cloud-console/package-lock.json ./
RUN npm ci --registry=${NPM_REGISTRY}
COPY cloud-console/ ./
RUN npm run build
# Stage 2: the existing Python image, now carrying the SPA build output.
ARG UV_IMAGE=ghcr.io/astral-sh/uv:python3.14-bookworm-slim
FROM ${UV_IMAGE}
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
@@ -7,10 +36,26 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \
ARG APT_MIRROR=
# bookworm uses /etc/apt/sources.list.d/debian.sources (DEB822 format); older
# Debian releases use /etc/apt/sources.list. Patch whichever exists.
RUN if [ -n "$APT_MIRROR" ]; then \
if [ -f /etc/apt/sources.list.d/debian.sources ]; then \
sed -i "s|deb.debian.org|$APT_MIRROR|g; s|security.debian.org|$APT_MIRROR|g" \
/etc/apt/sources.list.d/debian.sources; \
elif [ -f /etc/apt/sources.list ]; then \
sed -i "s|deb.debian.org|$APT_MIRROR|g; s|security.debian.org|$APT_MIRROR|g" \
/etc/apt/sources.list; \
fi; \
fi \
&& apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=frontend /app/dist /app/console-static
COPY . .
ARG UV_INDEX_URL=
ENV UV_INDEX_URL=${UV_INDEX_URL}
RUN uv sync --locked --all-packages --no-dev
ENV PATH="/app/.venv/bin:$PATH"
Vendored
+20 -1
View File
@@ -20,6 +20,15 @@ pipeline {
string(name: 'UV_INDEX_URL', defaultValue: 'https://mirrors.aliyun.com/pypi/simple', description: 'Optional PyPI mirror index URL for uv sync (empty = uv default)')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: 'Skip the uv sync/pytest stage (faster builds when only packaging)')
booleanParam(name: 'PUSH', defaultValue: true, description: 'Push the image to REGISTRY after a successful build')
// Mirror overrides passed to `docker build` as --build-arg. Defaults target
// CN networks so Jenkins builds don't time out pulling from Docker Hub /
// ghcr.io / npmjs.org / deb.debian.org. Override or blank any of these to
// build against the official upstreams.
string(name: 'NODE_IMAGE', defaultValue: 'registry.jerryyan.net/library/node:20-bookworm-slim', description: 'Stage 1 base image (Docker Hub library/node proxy)')
string(name: 'NPM_REGISTRY', defaultValue: 'https://registry.npmmirror.com', description: 'npm registry URL used by `npm ci`')
string(name: 'UV_IMAGE', defaultValue: 'registry-ghcr.jerryyan.top/astral-sh/uv:python3.14-bookworm-slim', description: 'Stage 2 base image (ghcr.io/astral-sh/uv proxy)')
string(name: 'APT_MIRROR', defaultValue: 'mirrors.aliyun.com', description: 'Debian apt mirror host (e.g. mirrors.aliyun.com). Empty = deb.debian.org')
}
environment {
@@ -79,7 +88,17 @@ pipeline {
stage('Build image') {
steps {
script {
def img = docker.build("${FULL_IMAGE}:${IMAGE_TAG}", '.')
// Pass every mirror override through as --build-arg. Empty values
// are skipped so the Dockerfile ARG default applies.
def buildArgs = []
["NODE_IMAGE", "NPM_REGISTRY", "UV_IMAGE", "APT_MIRROR", "UV_INDEX_URL"].each { name ->
def v = params[name]?.toString()?.trim()
if (v) {
buildArgs << "--build-arg ${name}=${v}"
}
}
def dockerArgs = (buildArgs.join(' ') + ' .').trim()
def img = docker.build("${FULL_IMAGE}:${IMAGE_TAG}", dockerArgs)
img.tag('latest')
env.BUILT_IMAGE = "${FULL_IMAGE}:${IMAGE_TAG}"
echo "Built ${env.BUILT_IMAGE} (+ :latest)"
+43 -1
View File
@@ -5,11 +5,15 @@ import logging
from collections.abc import Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from fastapi import FastAPI, status
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.types import Scope
from cloud.auth import (
ChainedAuthProvider,
@@ -19,6 +23,7 @@ from cloud.auth import (
)
from cloud.config import CloudConfig
from cloud.control_config import (
CloudConfigurationError,
CloudControlConfig,
load_control_config,
validate_control_config,
@@ -69,6 +74,25 @@ class CloudApplicationServices:
auth_provider: Any
class SpaStaticFiles(StaticFiles):
"""``StaticFiles`` variant that falls back to ``index.html`` for SPA routes.
``StaticFiles(html=True)`` only serves ``index.html`` for the mount root and
for directory roots; an unknown path like ``/console/tasks/abc`` raises a
plain 404, which breaks deep-link refreshes in a browser-running SPA. This
subclass intercepts 404s for non-asset paths and re-serves ``index.html``
so the SPA router can take over.
"""
async def get_response(self, path: str, scope: Scope) -> Any:
try:
return await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code == 404 and path != "index.html":
return await super().get_response("index.html", scope)
raise
def create_app(
*,
config: CloudControlConfig | None = None,
@@ -237,6 +261,24 @@ def create_app(
lease_duration_seconds=control_config.lease_duration_seconds,
)
)
if control_config.console_static_dir:
dist_dir = Path(control_config.console_static_dir)
if not dist_dir.is_dir():
raise CloudConfigurationError(
f"CLOUD_CONSOLE_STATIC_DIR is not a directory: {dist_dir}"
)
@app.get("/", include_in_schema=False)
async def _redirect_to_console() -> RedirectResponse:
return RedirectResponse(url="/console/")
app.mount(
"/console",
SpaStaticFiles(directory=str(dist_dir), html=True),
name="cloud-console",
)
return app
+107
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import time
from datetime import timedelta
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@@ -685,3 +686,109 @@ def test_load_control_config_defaults_to_empty_cors_allow_list() -> None:
}
)
assert config.cors_allowed_origins == ()
def test_load_control_config_parses_console_static_dir() -> None:
from cloud.control_config import load_control_config
config = load_control_config(
env={
"CLOUD_ENVIRONMENT": "local",
"CLOUD_DATABASE_URL": "sqlite:///:memory:",
"CLOUD_CONSOLE_STATIC_DIR": "/app/console-static",
}
)
assert config.console_static_dir == "/app/console-static"
def test_load_control_config_default_console_static_dir_is_none() -> None:
from cloud.control_config import load_control_config
config = load_control_config(
env={
"CLOUD_ENVIRONMENT": "local",
"CLOUD_DATABASE_URL": "sqlite:///:memory:",
}
)
assert config.console_static_dir is None
def _write_fake_dist(dist_dir: Path) -> None:
dist_dir.mkdir(parents=True, exist_ok=True)
(dist_dir / "index.html").write_text(
"<!doctype html><html><body>cloud console spa</body></html>",
encoding="utf-8",
)
assets = dist_dir / "assets"
assets.mkdir(exist_ok=True)
(assets / "index.js").write_text(
"console.log('spa boot');",
encoding="utf-8",
)
def test_console_static_dir_disabled_means_no_console_route() -> None:
app = create_app(
config=CloudControlConfig(database_url="sqlite:///:memory:")
)
with TestClient(app) as client:
# Without the static dir configured, `/console/` is not registered.
resp = client.get("/console/")
assert resp.status_code == 404
# And `/` is not redirected either.
root = client.get("/", follow_redirects=False)
assert root.status_code == 404
def test_console_static_dir_mounts_spa_with_fallback_and_redirect(tmp_path) -> None:
dist_dir = tmp_path / "console-static"
_write_fake_dist(dist_dir)
app = create_app(
config=CloudControlConfig(
database_url="sqlite:///:memory:",
console_static_dir=str(dist_dir),
)
)
with TestClient(app) as client:
# `/` redirects to the console mount so operators can visit the host root.
root = client.get("/", follow_redirects=False)
assert root.status_code in {301, 302, 307}
assert root.headers["location"] == "/console/"
# The mount root serves index.html.
index = client.get("/console/")
assert index.status_code == 200
assert "cloud console spa" in index.text
# Real asset files are served at their canonical path.
asset = client.get("/console/assets/index.js")
assert asset.status_code == 200
assert "spa boot" in asset.text
# Unknown SPA deep-link paths fall back to index.html so the Vue
# router can take over on a refresh.
deep = client.get("/console/tasks/anything")
assert deep.status_code == 200
assert "cloud console spa" in deep.text
# The `/v1/*` API surface is not hijacked by the SPA mount.
api_redirect = client.get("/v1/tasks", follow_redirects=False)
assert api_redirect.status_code == 401
assert api_redirect.headers["www-authenticate"] == "Bearer"
# And unknown API paths still return a JSON 404, not the SPA shell.
api_typo = client.get("/v1/this-route-does-not-exist")
assert api_typo.status_code == 404
assert api_typo.headers["content-type"].startswith("application/json")
def test_console_static_dir_rejects_missing_directory(tmp_path) -> None:
missing = tmp_path / "does-not-exist"
with pytest.raises(CloudConfigurationError, match="not a directory"):
create_app(
config=CloudControlConfig(
database_url="sqlite:///:memory:",
console_static_dir=str(missing),
)
)
+1
View File
@@ -3,4 +3,5 @@ import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
base: "/console/",
});
+1
View File
@@ -32,6 +32,7 @@ services:
CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3}
CLOUD_CONSOLE_STATIC_DIR: /app/console-static
ports:
- "${CLOUD_API_PORT:-8001}:8001"
depends_on:
+1
View File
@@ -33,6 +33,7 @@ services:
CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3}
CLOUD_CONSOLE_STATIC_DIR: /app/console-static
ports:
- "${CLOUD_API_PORT:-8001}:8001"
depends_on:
+24
View File
@@ -251,6 +251,30 @@ For a production build, run `npm run build` and serve the resulting `dist/`
behind any static file server or CDN, with `VITE_CLOUD_API_BASE_URL` baked in
at build time. The deployed origin must be in `CLOUD_CONSOLE_CORS_ORIGINS`.
### Same-origin deployment (baked into the Cloud API image)
The Jenkins-built Docker image already carries the SPA at `/app/console-static`,
and `compose.yaml` / `compose.deploy.yaml` set
`CLOUD_CONSOLE_STATIC_DIR=/app/console-static` on the `cloud-api` service. In
this mode the Cloud API itself serves the console at `/console/` (visiting `/`
307-redirects there), so operators can open `https://<cloud-api-host>:8001/`
directly — no separate dev server, no static host, no CORS allow-list needed
(the SPA and the API share one origin).
The SPA shell (`index.html`, JS, CSS) is served without a bearer token by
design — `_authorize(...)` is called inside the `/v1/*` route handlers, not in
middleware, so the SPA can boot before the operator pastes a token. All
`/v1/*` API calls still require `tasks:read`/`pool:read`/`plugins:read` scopes
as before.
To opt out (e.g. for local development where you run `npm run dev`), leave
`CLOUD_CONSOLE_STATIC_DIR` unset. The mount is conditional on that env var.
Jenkins build args (`NODE_IMAGE`, `NPM_REGISTRY`, `UV_IMAGE`, `APT_MIRROR`,
`UV_INDEX_URL`) default to CN mirrors so builds don't time out pulling from
Docker Hub / ghcr.io / npmjs.org / deb.debian.org. Blank any of them to fall
back to the upstream.
## Runtime AI Planner
The Host Agent reuses the local Runtime planner. AI planning is disabled by
+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.
@@ -33,6 +33,7 @@ class CloudControlConfig:
credentials: tuple[BearerCredential, ...] = ()
enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
cors_allowed_origins: tuple[str, ...] = ()
console_static_dir: str | None = None
def load_control_config(
@@ -94,6 +95,9 @@ def load_control_config(
cors_allowed_origins=_parse_cors_origins(
values.get("CLOUD_CONSOLE_CORS_ORIGINS")
),
console_static_dir=_parse_optional_string(
values.get("CLOUD_CONSOLE_STATIC_DIR")
),
)
validate_control_config(config)
return config
@@ -229,3 +233,10 @@ def _parse_cors_origins(raw_value: str | None) -> tuple[str, ...]:
if raw_value is None or not raw_value.strip():
return ()
return tuple(origin.strip() for origin in raw_value.split(",") if origin.strip())
def _parse_optional_string(raw_value: str | None) -> str | None:
if raw_value is None:
return None
stripped = raw_value.strip()
return stripped or None
+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()