diff --git a/artemis/drivers/android/adb_driver.py b/artemis/drivers/android/adb_driver.py index 1abbece..ea26f0a 100644 --- a/artemis/drivers/android/adb_driver.py +++ b/artemis/drivers/android/adb_driver.py @@ -30,6 +30,7 @@ from artemis.config.agent import load_agent_config 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.toolchain import find_ffmpeg, find_scrcpy from artemis.utils.video import build_scrcpy_record_command from artemis.utils.ui_filter import filter_ui_hierarchy @@ -450,15 +451,9 @@ class AndroidAdbDriver(BaseDeviceDriver): return False async def launch_app(self, package_name: str) -> bool: - try: - cmd = f"monkey -p {package_name} -c android.intent.category.LAUNCHER 1" - await asyncio.to_thread( - self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS - ) - return True - except Exception as e: - logger.error(f"Launch app failed for '{package_name}': {e}") - return False + return await launch_android_app( + self.device, package_name, settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS + ) async def stop_app(self, package_name: str) -> bool: try: diff --git a/artemis/drivers/android/app_launch.py b/artemis/drivers/android/app_launch.py new file mode 100644 index 0000000..56689e0 --- /dev/null +++ b/artemis/drivers/android/app_launch.py @@ -0,0 +1,96 @@ +"""Launch Android activities through Activity Manager, without requiring Monkey.""" + +import asyncio +import re + +from adbutils import AdbDevice + +from artemis.drivers.errors import AppLaunchError + +_PACKAGE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*") +_COMPONENT = re.compile(r"([\w.]+)/([\w.$]+)", re.ASCII) +_ERROR = re.compile( + r"^\s*(?:error\b|exception\b|securityexception\b|permission denial\b|" + r"[\w.]+Exception\b)", + re.IGNORECASE | re.MULTILINE, +) +_TRANSIENT = re.compile(r"timed? out|timeout|deadobjectexception|can't find service", re.IGNORECASE) +_UNSUPPORTED = re.compile( + r"unknown (?:command|option)|unrecognized (?:command|option)", re.IGNORECASE +) + + +def _failure(package: str, stage: str, code: int, output: str, **kwargs: bool) -> AppLaunchError: + return AppLaunchError( + f"应用 {package} {stage}失败(退出码 {code}):{output.strip()[:1500] or '命令没有返回结果'}", + **kwargs, + ) + + +async def _shell( + device: AdbDevice, command: list[str], package: str, stage: str, timeout: float +) -> tuple[int, str]: + try: + result = await asyncio.to_thread(device.shell2, command, timeout=timeout) + return result.returncode, result.output + except Exception as exc: + # A transport timeout can happen after Android has already received start. + # The caller must inspect the foreground before issuing another launch. + raise AppLaunchError( + f"应用 {package} {stage}时 ADB 请求失败:{str(exc)[:1500] or type(exc).__name__}", + retryable=True, + may_have_started=stage == "启动", + ) from exc + + +async def launch_android_app(device: AdbDevice, package: str, timeout: float) -> bool: + """Resolve the current user's launcher and request a normal launcher-style start. + + True means Activity Manager accepted the command; the shared launch workflow + still verifies the foreground. Deterministic failures raise AppLaunchError so + they do not turn into repeated foreground timeouts. + """ + if not _PACKAGE.fullmatch(package): + raise AppLaunchError(f"无效的 Android 应用包名:{package!r}") + + intent = ["-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER"] + resolve = ["resolve-activity", "--brief", "--user", "current", *intent, "-p", package] + component = None + # pm is the compatibility path for systems without cmd package. If neither + # exposes resolution, let Activity Manager resolve the package-scoped intent. + for prefix in (["cmd", "package"], ["pm"]): + code, output = await _shell(device, [*prefix, *resolve], package, "查询启动入口", timeout) + if code == 127 or _UNSUPPORTED.search(output): + continue + if code != 0 or _ERROR.search(output): + raise _failure( + package, "查询启动入口", code, output, retryable=bool(_TRANSIENT.search(output)) + ) + candidates = [ + match.group(0) + for line in output.splitlines() + if (match := _COMPONENT.fullmatch(line.strip())) and match.group(1) == package + ] + if len(candidates) != 1: + raise _failure( + package, + "查询启动入口", + code, + f"未找到唯一可启动入口;应用可能未安装、已停用或没有桌面入口。{output.strip()}", + ) + component = candidates[0] + break + + target = ["-n", component] if component else ["-p", package] + # Match a launcher tap: NEW_TASK | RESET_TASK_IF_NEEDED, preserving warm tasks. + command = ["am", "start", "-W", "--user", "current", *intent, "-f", "0x10200000", *target] + code, output = await _shell(device, command, package, "启动", timeout) + status = re.search(r"^\s*Status:\s*(\S+)", output, re.IGNORECASE | re.MULTILINE) + if code != 0 or _ERROR.search(output) or (status and status[1].lower() != "ok"): + transient = bool(_TRANSIENT.search(output)) + raise _failure( + package, "启动", code, output, retryable=transient, may_have_started=transient + ) + if not status and not re.search(r"Starting:\s*Intent|Warning:\s*Activity not started", output): + raise _failure(package, "启动", code, output, retryable=True, may_have_started=True) + return True diff --git a/artemis/drivers/base.py b/artemis/drivers/base.py index 468787b..ee101af 100644 --- a/artemis/drivers/base.py +++ b/artemis/drivers/base.py @@ -157,7 +157,10 @@ class BaseDeviceDriver(ABC): @abstractmethod async def launch_app(self, package_name: str) -> bool: - """Launches target application package into the foreground.""" + """Requests launch; may raise AppLaunchError with actionable failure details. + + Callers must independently verify that the application reached the foreground. + """ ... @abstractmethod diff --git a/artemis/drivers/errors.py b/artemis/drivers/errors.py new file mode 100644 index 0000000..912b9ca --- /dev/null +++ b/artemis/drivers/errors.py @@ -0,0 +1,10 @@ +"""Action failures that the shared controller can classify without parsing text.""" + + +class AppLaunchError(RuntimeError): + """A launch failure with bounded-retry and uncertain-execution semantics.""" + + def __init__(self, message: str, *, retryable: bool = False, may_have_started: bool = False): + super().__init__(message) + self.retryable = retryable + self.may_have_started = may_have_started diff --git a/artemis/utils/app_launch_utils.py b/artemis/utils/app_launch_utils.py index 6a9ecfa..4968b19 100644 --- a/artemis/utils/app_launch_utils.py +++ b/artemis/utils/app_launch_utils.py @@ -28,6 +28,7 @@ from artemis.controllers.platform_specific_commands_controller import ( ) from artemis.controllers.unified_controller import UnifiedMobileController from artemis.data_engine.trace import TraceSpan +from artemis.drivers.errors import AppLaunchError from artemis.utils.logger import get_logger logger = get_logger(__name__) @@ -285,6 +286,7 @@ async def launch_app_with_retries( Tuple of (success: bool, error_message: str | None) """ + last_error: str | None = None for attempt in range(1, max_retries + 1): logger.info(f"Launch attempt {attempt}/{max_retries} for app {app_package}") @@ -304,13 +306,32 @@ async def launch_app_with_retries( await controller.terminate_app(app_package) await asyncio.sleep(1.0) - launch_success = await controller.launch_app(app_package) + launch_error: AppLaunchError | None = None + try: + launch_success = await controller.launch_app(app_package) + except AppLaunchError as exc: + launch_success = False + launch_error = exc + span.payload["retryable"] = exc.retryable + span.payload["launch_error"] = str(exc) + if exc.may_have_started: + logger.warning(f"{exc};先核对应用是否已进入前台,避免重复启动。") + ready, _ = await _poll_for_app_ready(ctx, app_package, max_poll_seconds) + if ready: + span.status = "success" + span.result = "App is ready (launch acknowledgement was unavailable)" + return True, None if not launch_success: - error_msg = f"Failed to execute launch command for {app_package}" + error_msg = ( + str(launch_error) + if launch_error + else (f"Failed to execute launch command for {app_package}") + ) + last_error = error_msg logger.error(error_msg) span.status = "failed" span.error = error_msg - if attempt == max_retries: + if attempt == max_retries or (launch_error and not launch_error.retryable): return False, error_msg await asyncio.sleep(2) continue @@ -326,12 +347,15 @@ async def launch_app_with_retries( span.status = "failed" span.error = error_msg + last_error = error_msg if attempt < max_retries: logger.warning(f"Attempt {attempt} failed: {error_msg}. Retrying...") await asyncio.sleep(1) error_msg = f"Failed to launch {app_package} after {max_retries} attempts" + if last_error: + error_msg += f": {last_error}" logger.error(error_msg) return False, error_msg diff --git a/docs/ai-automation.md b/docs/ai-automation.md index 8add296..33323aa 100644 --- a/docs/ai-automation.md +++ b/docs/ai-automation.md @@ -64,6 +64,8 @@ MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当 UIAutomator2 启动失败时,先释放本次连接启动的服务,再按现有策略重试;最终错误保留设备编号、尝试次数和底层原因。初始化失败且没有操作步骤时,用例目标属于未执行、未验证,应先检查设备诊断和启动日志,不能据此认定被测功能失败。 +Android 应用启动通过系统包管理器查找当前用户的桌面入口,再调用 `am start -W`,不依赖模拟器是否内置 `monkey`。检查命令退出码、错误输出和实际前台状态;应用未安装、没有桌面入口或权限拒绝等明确错误直接返回原因,不重复等待启动超时。ADB 通信异常沿用最多 3 次启动尝试;启动回执超时时先确认应用是否已进入前台,避免重复启动已成功打开的应用。 + Flash 和 Pro 默认复用只读 Checker,以任务提示词和用户指导中的要求核对执行结论、步骤截图及探测证据,无需另填预期结果。`test_result` 包含 `status`(`passed`、`failed`、`inconclusive`、`blocked`)、中文 `reason` 和 `evidence`;任务列表、报告与 MCP 使用同一结论。`completed` 仍只代表执行结束,可同时存在测试不通过。Flash 只在退出时验收,不添加计划或中途检查;沿用 Checker 的超时和开关,关闭、超时或缺少证据时显示未验证,不重跑用例。 `mobile_run_task(runtime_overrides={"timeout_seconds":1800,"observation_timeout_seconds":60,"observation_max_attempts":3,"device_command_timeout_seconds":10,"action_failure_attempts":3})` 可为单任务覆盖执行保护;其中 `model` 是 API 模型名,外层 `model` 仍是 Flash/Pro 模式。相同字段也支持批次的 `task_config`。全局默认通过网页功能配置或明确授权的 `mobile_config(section="capabilities", changes={"task_timeout_seconds":1800}, persist_defaults=true)` 保存,对后续任务生效,不接受密钥。 diff --git a/tests/unit/test_android_app_launch.py b/tests/unit/test_android_app_launch.py new file mode 100644 index 0000000..b1b50df --- /dev/null +++ b/tests/unit/test_android_app_launch.py @@ -0,0 +1,137 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from artemis.drivers.android.adb_driver import AndroidAdbDriver +from artemis.drivers.errors import AppLaunchError + + +PACKAGE = "com.example.app" +COMPONENT = f"{PACKAGE}/.MainActivity" + + +def response(output, code=0): + return SimpleNamespace(returncode=code, output=output) + + +@pytest.fixture +def launch_device(): + device = MagicMock() + # An emulator without monkey must work, without installing or invoking it. + device.shell.side_effect = AssertionError("Unexpected unchecked shell/monkey call") + driver = AndroidAdbDriver("test-device", MagicMock()) + driver._device = device + return driver, device + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "output", + [ + "Starting: Intent {}\nStatus: ok\nComplete", + "Starting: Intent {}\nWarning: Activity not started, its current task has been brought to the front", + "Warning: Activity not started, intent has been delivered to currently running top-most instance.", + ], +) +async def test_launch_without_monkey_checks_start_result(launch_device, output): + driver, device = launch_device + device.shell2.side_effect = [response(f"priority=0\n{COMPONENT}"), response(output)] + assert await driver.launch_app(PACKAGE) is True + calls = device.shell2.call_args_list + assert calls[0].args[0][:3] == ["cmd", "package", "resolve-activity"] + assert calls[1].args[0][-2:] == ["-n", COMPONENT] + assert calls[1].args[0][:5] == ["am", "start", "-W", "--user", "current"] + assert "0x10200000" in calls[1].args[0] + assert all(call.kwargs["timeout"] > 0 for call in calls) + device.shell.assert_not_called() + + +@pytest.mark.asyncio +async def test_falls_back_to_pm_when_cmd_is_missing(launch_device): + driver, device = launch_device + device.shell2.side_effect = [ + response("/system/bin/sh: cmd: inaccessible or not found", 127), + response(COMPONENT), + response("Status: ok"), + ] + assert await driver.launch_app(PACKAGE) is True + assert device.shell2.call_args_list[1].args[0][:2] == ["pm", "resolve-activity"] + + +@pytest.mark.asyncio +async def test_old_resolvers_use_package_scoped_intent(launch_device): + driver, device = launch_device + device.shell2.side_effect = [ + response("Unknown command: resolve-activity", 1), + response("Unknown command: resolve-activity", 1), + response("Status: ok"), + ] + assert await driver.launch_app(PACKAGE) is True + assert device.shell2.call_args_list[-1].args[0][-2:] == ["-p", PACKAGE] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("output", ["No activity found", "", "android/.ResolverActivity"]) +async def test_missing_or_ambiguous_launcher_does_not_start(launch_device, output): + driver, device = launch_device + device.shell2.return_value = response(output) + with pytest.raises(AppLaunchError, match="未找到唯一可启动入口") as error: + await driver.launch_app(PACKAGE) + assert error.value.retryable is False + assert device.shell2.call_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "output, code", + [ + ("Starting: Intent {}\nError type 3\nError: Activity class does not exist.", 0), + ("java.lang.SecurityException: Permission Denial: not exported", 0), + ("Error: Permission Denial", 1), + ("/system/bin/sh: am: inaccessible or not found", 127), + ("Starting: Intent {}\nStatus: error", 0), + ], +) +async def test_start_errors_are_retained_even_when_exit_zero(launch_device, output, code): + driver, device = launch_device + device.shell2.side_effect = [response(COMPONENT), response(output, code)] + with pytest.raises(AppLaunchError) as error: + await driver.launch_app(PACKAGE) + assert output in str(error.value) + assert f"退出码 {code}" in str(error.value) + assert error.value.retryable is False + assert error.value.may_have_started is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", [TimeoutError(), response("Status: timeout"), response("")]) +async def test_unknown_start_outcome_requires_foreground_check(launch_device, failure): + driver, device = launch_device + device.shell2.side_effect = [response(COMPONENT), failure] + with pytest.raises(AppLaunchError) as error: + await driver.launch_app(PACKAGE) + assert error.value.retryable is True + assert error.value.may_have_started is True + + +@pytest.mark.asyncio +async def test_resolve_transport_failure_does_not_request_a_launch(launch_device): + driver, device = launch_device + device.shell2.side_effect = OSError("device offline") + with pytest.raises(AppLaunchError, match="device offline") as error: + await driver.launch_app(PACKAGE) + assert error.value.retryable is True + assert error.value.may_have_started is False + assert device.shell2.call_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "package", ["", "com.example.app;reboot", "com.example.app/.Activity", "a b"] +) +async def test_invalid_package_rejected_before_adb(launch_device, package): + driver, device = launch_device + with pytest.raises(AppLaunchError, match="无效"): + await driver.launch_app(package) + device.shell2.assert_not_called() diff --git a/tests/unit/utils/test_app_launch_utils.py b/tests/unit/utils/test_app_launch_utils.py index 7c988ab..908bf10 100644 --- a/tests/unit/utils/test_app_launch_utils.py +++ b/tests/unit/utils/test_app_launch_utils.py @@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, Mock, patch from artemis.context import ArtemisContext +from artemis.drivers.errors import AppLaunchError from artemis.utils.app_launch_utils import ( ForegroundTask, _handle_initial_app_launch, @@ -375,7 +376,7 @@ async def test_launch_timeout_message_names_foreground_package_and_task( mock_get_task.return_value = parse_foreground_task(DUMP_SEARCH_IN_OWN_TASK) with patch("artemis.utils.app_launch_utils.logger") as mock_logger: - success, _ = await launch_app_with_retries( + success, detail = await launch_app_with_retries( mock_context, SETTINGS, max_retries=1, max_poll_seconds=1 ) @@ -383,7 +384,7 @@ async def test_launch_timeout_message_names_foreground_package_and_task( timeout_messages = [ call.args[0] for call in mock_logger.error.call_args_list - if "Timeout waiting" in call.args[0] + if call.args[0].startswith("Timeout waiting") ] assert len(timeout_messages) == 1 message = timeout_messages[0] @@ -391,6 +392,7 @@ async def test_launch_timeout_message_names_foreground_package_and_task( assert "task #120" in message assert f"base={SETTINGS_SEARCH}" in message assert f"resumed={SETTINGS_SEARCH}/.modules.search.SearchActivity" in message + assert message in detail @pytest.mark.asyncio @@ -418,6 +420,56 @@ async def test_launch_app_null_focus_keeps_polling_without_reading_task( # --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_deterministic_launch_failure_skips_polling_and_retry(mock_context): + error = AppLaunchError("am 启动失败:Permission Denial(退出码 1)") + with ( + patch("artemis.utils.app_launch_utils.UnifiedMobileController") as controller_cls, + patch("artemis.utils.app_launch_utils._poll_for_app_ready", new_callable=AsyncMock) as poll, + ): + controller = _controller(controller_cls) + controller.launch_app.side_effect = error + result, detail = await launch_app_with_retries(mock_context, SETTINGS) + assert result is False + assert detail == str(error) + controller.launch_app.assert_awaited_once() + controller.terminate_app.assert_not_called() + poll.assert_not_called() + + +@pytest.mark.asyncio +async def test_start_acknowledgement_timeout_checks_foreground_before_relaunch(mock_context): + with ( + patch("artemis.utils.app_launch_utils.UnifiedMobileController") as controller_cls, + patch("artemis.utils.app_launch_utils._poll_for_app_ready", new_callable=AsyncMock) as poll, + ): + controller = _controller(controller_cls) + controller.launch_app.side_effect = AppLaunchError( + "ADB timeout", retryable=True, may_have_started=True + ) + poll.return_value = (True, None) + assert await launch_app_with_retries(mock_context, SETTINGS) == (True, None) + controller.launch_app.assert_awaited_once() + controller.terminate_app.assert_not_called() + poll.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_transient_launch_failure_is_bounded_and_keeps_cause(mock_context): + with ( + patch("artemis.utils.app_launch_utils.UnifiedMobileController") as controller_cls, + patch("artemis.utils.app_launch_utils._poll_for_app_ready", new_callable=AsyncMock) as poll, + patch("artemis.utils.app_launch_utils.asyncio.sleep", new_callable=AsyncMock), + ): + controller = _controller(controller_cls) + controller.launch_app.side_effect = AppLaunchError("device offline", retryable=True) + result, detail = await launch_app_with_retries(mock_context, SETTINGS) + assert result is False + assert detail == "device offline" + assert controller.launch_app.await_count == 3 + poll.assert_not_called() + + @pytest.mark.asyncio @patch("artemis.utils.app_launch_utils.launch_app_with_retries") @patch("artemis.utils.app_launch_utils.get_foreground_task")