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:
chenmingxuan committed 2026-10-27 01:13:41 +08:00
1 parent fac4ccb760
commit be6e8c2abd
79 files changed
+2263 -277

No files matched your search

+49 -16
View File
@@ -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,