feat: release 2.2.0 with PC-only tests and execution reliability fixes
Unify task scheduling and queue state, fix Android input and observation recovery, preserve screenshot paths, and match final review criteria accurately. Add collapsible copyable results and publish the complete 2.1.0 upgrade notes. Validation: 3189 backend tests, 326 frontend tests, 24 client tests, production build and quality gates.
This commit is contained in:
1 parent
fac4ccb760
commit
be6e8c2abd
79 files changed
+2263
-277
No files matched your search
@@ -18,6 +18,7 @@ import asyncio
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from time import monotonic
|
||||
from typing import Any, Literal
|
||||
from PIL import Image
|
||||
|
||||
@@ -31,6 +32,12 @@ from artemis.config.settings import settings
|
||||
from artemis.config.paths import get_temp_dir
|
||||
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
|
||||
from artemis.drivers.android.app_launch import launch_android_app
|
||||
from artemis.drivers.android.input_ime import clear_focused_input
|
||||
from artemis.runtime.observation_budget import (
|
||||
ObservationBudget,
|
||||
check_observation,
|
||||
current_observation,
|
||||
)
|
||||
from artemis.toolchain import find_ffmpeg, find_scrcpy
|
||||
from artemis.utils.video import build_scrcpy_record_command
|
||||
from artemis.utils.ui_filter import filter_ui_hierarchy
|
||||
@@ -95,6 +102,8 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
self._recording_output_path: Path | None = None
|
||||
self._screenshot_config = load_agent_config().screenshot
|
||||
self._screen_capture_task: asyncio.Task[ScreenData] | None = None
|
||||
self._observation_budget: ObservationBudget | None = None
|
||||
self._disconnected = False
|
||||
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
@@ -112,6 +121,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Verify device availability."""
|
||||
self._disconnected = False
|
||||
try:
|
||||
state = self.device.get_state()
|
||||
logger.info(f"Android device '{self._device_id}' connected (state: {state})")
|
||||
@@ -120,6 +130,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Clean up ongoing recordings and tunnels."""
|
||||
self._disconnected = True
|
||||
if self._observation_budget is not None:
|
||||
self._observation_budget.cancelled.set()
|
||||
if self._recording_process:
|
||||
await self.stop_video_recording()
|
||||
if self._ui_adb_client and hasattr(self._ui_adb_client, "disconnect"):
|
||||
@@ -127,9 +140,16 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
|
||||
async def get_screen_data(self, skip_settling: bool = False) -> ScreenData:
|
||||
"""Bound observation latency and share unfinished capture work across retries."""
|
||||
if self._disconnected:
|
||||
raise RuntimeError("设备观测已断开")
|
||||
task = self._screen_capture_task
|
||||
if task is None or task.done():
|
||||
task = asyncio.create_task(self._get_screen_data(skip_settling))
|
||||
self._observation_budget = ObservationBudget(
|
||||
monotonic() + settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS
|
||||
)
|
||||
task = asyncio.create_task(
|
||||
self._capture_with_budget(self._observation_budget, skip_settling)
|
||||
)
|
||||
# A timed-out caller does not cancel a blocking SDK thread or start
|
||||
# another concurrent read on the same device. Retrieve late errors.
|
||||
task.add_done_callback(lambda done: None if done.cancelled() else done.exception())
|
||||
@@ -139,10 +159,22 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
asyncio.shield(task), timeout=settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
detail = self._observation_budget.detail if self._observation_budget else str(exc)
|
||||
raise TimeoutError(
|
||||
f"设备屏幕观测超时({settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS:g} 秒)"
|
||||
f"设备屏幕观测超时({settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS:g} 秒);{detail}"
|
||||
) from exc
|
||||
|
||||
async def _capture_with_budget(
|
||||
self, budget: ObservationBudget, skip_settling: bool
|
||||
) -> ScreenData:
|
||||
token = current_observation.set(budget)
|
||||
try:
|
||||
result = await self._get_screen_data(skip_settling)
|
||||
budget.remaining()
|
||||
return result
|
||||
finally:
|
||||
current_observation.reset(token)
|
||||
|
||||
async def _get_screen_data(self, skip_settling: bool = False) -> ScreenData:
|
||||
"""Captures screenshot and XML hierarchy concurrently."""
|
||||
if not skip_settling:
|
||||
@@ -158,6 +190,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
# Check if ui_adb_client has direct screen data support
|
||||
if self._ui_adb_client and hasattr(self._ui_adb_client, "get_screen_data"):
|
||||
try:
|
||||
check_observation("屏幕辅助服务 / UIAutomator2 观测")
|
||||
raw_ui_data = await asyncio.to_thread(self._ui_adb_client.get_screen_data)
|
||||
if asyncio.iscoroutine(raw_ui_data):
|
||||
raw_ui_data = await raw_ui_data
|
||||
@@ -185,14 +218,20 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
except Exception as e:
|
||||
capture_errors.append(str(e))
|
||||
logger.debug(f"Direct ui_adb_client.get_screen_data error: {e}")
|
||||
check_observation(failure=e)
|
||||
|
||||
if not screenshot_base64 or not isinstance(screenshot_base64, str):
|
||||
try:
|
||||
check_observation("ADB 截图")
|
||||
budget = current_observation.get()
|
||||
timeout = settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
|
||||
if budget is not None:
|
||||
timeout = min(timeout, budget.remaining())
|
||||
raw_image = await asyncio.to_thread(
|
||||
self.device.shell,
|
||||
["screencap", "-p"],
|
||||
encoding=None,
|
||||
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||
timeout=timeout,
|
||||
)
|
||||
pil_img = Image.open(BytesIO(raw_image))
|
||||
if hasattr(pil_img, "save"):
|
||||
@@ -204,6 +243,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
except Exception as e:
|
||||
capture_errors.append(str(e))
|
||||
logger.warning(f"Device screenshot capture failed on {self._device_id}: {e}")
|
||||
check_observation(failure=e)
|
||||
|
||||
if not screenshot_base64 or not isinstance(screenshot_base64, str):
|
||||
raise RuntimeError(
|
||||
@@ -218,6 +258,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
# 2. Capture UI Hierarchy via UIAutomator
|
||||
if self._ui_adb_client and not ui_elements:
|
||||
try:
|
||||
check_observation("获取界面层级")
|
||||
if hasattr(self._ui_adb_client, "get_hierarchy"):
|
||||
res = await asyncio.to_thread(self._ui_adb_client.get_hierarchy)
|
||||
hierarchy = await res if asyncio.iscoroutine(res) else res
|
||||
@@ -351,19 +392,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
async def input_text(self, text: str, clear_existing: bool = True) -> bool:
|
||||
try:
|
||||
if clear_existing:
|
||||
# Safe & robust clearing: Move to End -> Shift+Home selection -> Delete -> Fallback backspaces
|
||||
clear_cmd = (
|
||||
"input keyevent 123 && "
|
||||
"input keyevent --meta 1 122 && "
|
||||
"input keyevent 67 && "
|
||||
"input keyevent 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
self.device.shell,
|
||||
clear_cmd,
|
||||
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
else:
|
||||
if not await clear_focused_input(self._ui_adb_client):
|
||||
return False
|
||||
if not text:
|
||||
return True
|
||||
if not clear_existing:
|
||||
# Append mode: move cursor to the very end of existing text
|
||||
await asyncio.to_thread(
|
||||
self.device.shell,
|
||||
|
||||
Reference in new issue
Block a user