Compare commits
2
Commits
d673e77171
...
50f0b8ade9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50f0b8ade9 | ||
|
|
2ccbc63d95 |
@@ -8,3 +8,5 @@ __pycache__
|
||||
*.sqlite3
|
||||
tasks
|
||||
console/node_modules
|
||||
cloud-console/node_modules
|
||||
cloud-console/dist
|
||||
|
||||
+47
-2
@@ -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
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,4 +3,5 @@ import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: "/console/",
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 也没有公开的
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user