fix(android): launch apps with checked activity manager commands
This commit is contained in:
1 parent
ef7af9988e
commit
f86db1ced6
8 files changed
+334
-15
No files matched your search
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user