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.
317 lines
11 KiB
Python
317 lines
11 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Unit tests for Unified Base Device Driver and implementations."""
|
|
|
|
import asyncio
|
|
import base64
|
|
import threading
|
|
from io import BytesIO
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from artemis.drivers.base import KeyCode, ScreenData, SwipeDirection
|
|
from artemis.drivers.mock.mock_driver import MockDeviceDriver
|
|
from artemis.drivers.android.adb_driver import AndroidAdbDriver
|
|
from artemis.config.agent import ScreenshotConfig
|
|
from artemis.utils.screenshot_limit import limit_screenshot_long_edge
|
|
|
|
|
|
_ONE_PIXEL_PNG = base64.b64encode(
|
|
base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
|
)
|
|
).decode("ascii")
|
|
|
|
|
|
def _valid_png():
|
|
buffer = BytesIO()
|
|
Image.new("RGB", (64, 64), "white").save(buffer, format="PNG")
|
|
return base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
|
|
|
|
_VALID_PNG = _valid_png()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_prevents_late_helper_failure_from_starting_uiautomator():
|
|
from artemis.clients.accessibility_client import HelperUnavailable
|
|
from artemis.clients.screen_client_factory import FallbackScreenClient
|
|
|
|
started, released = threading.Event(), threading.Event()
|
|
helper, factory, adb = MagicMock(), MagicMock(), MagicMock()
|
|
|
|
def capture():
|
|
started.set()
|
|
released.wait(2)
|
|
raise HelperUnavailable("late snapshot failure")
|
|
|
|
helper.get_screen_data.side_effect = capture
|
|
screen = FallbackScreenClient("test-device", helper=helper, uiautomator_factory=factory)
|
|
driver = AndroidAdbDriver("test-device", adb, ui_adb_client=screen)
|
|
pending = asyncio.create_task(driver.get_screen_data(skip_settling=True))
|
|
try:
|
|
assert await asyncio.to_thread(started.wait, 1)
|
|
await driver.disconnect()
|
|
finally:
|
|
released.set()
|
|
with pytest.raises(RuntimeError, match="观测已取消"):
|
|
await asyncio.wait_for(pending, 2)
|
|
factory.assert_not_called()
|
|
adb.device.assert_not_called()
|
|
assert screen.active_backend is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_android_driver_rejects_placeholder_without_returning_stale_screen():
|
|
client = MagicMock()
|
|
client.get_screen_data.return_value = SimpleNamespace(base64=_ONE_PIXEL_PNG)
|
|
driver = AndroidAdbDriver("test-device", MagicMock(), ui_adb_client=client)
|
|
with pytest.raises(RuntimeError, match="占位图"):
|
|
await driver.get_screen_data(skip_settling=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_screen_capture_timeout_does_not_start_overlapping_sdk_calls(monkeypatch):
|
|
released = threading.Event()
|
|
client = MagicMock()
|
|
|
|
def blocked_capture():
|
|
released.wait(2)
|
|
return SimpleNamespace(base64=_VALID_PNG, width=64, height=64, elements=[])
|
|
|
|
client.get_screen_data.side_effect = blocked_capture
|
|
driver = AndroidAdbDriver("test-device", MagicMock(), ui_adb_client=client)
|
|
monkeypatch.setattr(
|
|
"artemis.drivers.android.adb_driver.settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS", 0.02
|
|
)
|
|
try:
|
|
for _ in range(3):
|
|
with pytest.raises(TimeoutError, match="观测超时"):
|
|
await driver.get_screen_data(skip_settling=True)
|
|
assert client.get_screen_data.call_count == 1
|
|
finally:
|
|
released.set()
|
|
with pytest.raises(TimeoutError, match="预算耗尽"):
|
|
await asyncio.wait_for(driver._screen_capture_task, 2)
|
|
client.get_hierarchy.assert_not_called()
|
|
|
|
|
|
def test_screenshot_limit_keeps_small_and_invalid_images_unchanged():
|
|
small_image = base64.b64decode(_ONE_PIXEL_PNG)
|
|
assert limit_screenshot_long_edge(small_image, 2048) is small_image
|
|
invalid_image = b"not an image"
|
|
assert limit_screenshot_long_edge(invalid_image, 2048) is invalid_image
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mock_driver_actions():
|
|
"""Verify MockDeviceDriver records and performs all core actions."""
|
|
driver = MockDeviceDriver(device_id="test-mock", width=1080, height=2400)
|
|
assert driver.device_id == "test-mock"
|
|
assert driver.screen_size == (1080, 2400)
|
|
|
|
# 1. Screen data
|
|
screen_data = await driver.get_screen_data()
|
|
assert isinstance(screen_data, ScreenData)
|
|
assert screen_data.width == 1080
|
|
assert screen_data.height == 2400
|
|
assert screen_data.screenshot_base64 is not None
|
|
|
|
# 2. Tap
|
|
assert await driver.tap(500, 1000) is True
|
|
assert driver.action_history[-1]["action"] == "tap"
|
|
assert driver.action_history[-1]["x"] == 500
|
|
|
|
# 3. Swipe
|
|
assert await driver.swipe(100, 200, 300, 400) is True
|
|
assert driver.action_history[-1]["action"] == "swipe"
|
|
|
|
# 4. Swipe direction
|
|
assert await driver.swipe_direction(SwipeDirection.UP) is True
|
|
assert driver.action_history[-1]["direction"] == "up"
|
|
|
|
# 5. Input text
|
|
assert await driver.input_text("Hello Artemis") is True
|
|
assert driver.action_history[-1]["text"] == "Hello Artemis"
|
|
|
|
# 6. Press key
|
|
assert await driver.press_key(KeyCode.HOME) is True
|
|
assert driver.action_history[-1]["key"] == "home"
|
|
|
|
# 7. App lifecycle
|
|
assert await driver.launch_app("com.android.settings") is True
|
|
assert await driver.get_current_package() == "com.android.settings"
|
|
assert await driver.stop_app("com.android.settings") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_android_driver_with_mock_adb():
|
|
"""Verify AndroidAdbDriver formats shell commands properly."""
|
|
mock_adb_client = MagicMock()
|
|
mock_adb_device = MagicMock()
|
|
mock_adb_client.device.return_value = mock_adb_device
|
|
|
|
driver = AndroidAdbDriver(
|
|
device_id="emulator-5554",
|
|
adb_client=mock_adb_client,
|
|
width=1080,
|
|
height=2400,
|
|
)
|
|
|
|
# Test tap
|
|
await driver.tap(540, 1200)
|
|
mock_adb_device.shell.assert_called_with("input tap 540 1200", timeout=10.0)
|
|
|
|
# Test multi-tap
|
|
await driver.tap(540, 1200, times=3, delay_ms=100)
|
|
expected_multi = "input tap 540 1200 && sleep 0.100 && input tap 540 1200 && sleep 0.100 && input tap 540 1200"
|
|
mock_adb_device.shell.assert_called_with(expected_multi, timeout=10.0)
|
|
|
|
# Test press key
|
|
await driver.press_key(KeyCode.BACK)
|
|
mock_adb_device.shell.assert_called_with("input keyevent 4", timeout=10.0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_android_driver_preserves_elements_from_combined_screen_data():
|
|
"""The combined uiautomator2 response is the authoritative live snapshot."""
|
|
mock_adb_client = MagicMock()
|
|
mock_ui_client = MagicMock()
|
|
expected_elements = [
|
|
{
|
|
"text": "",
|
|
"class": "android.widget.FrameLayout",
|
|
"bounds": "[0,0][1080,2400]",
|
|
"clickable": "false",
|
|
"focusable": "false",
|
|
},
|
|
{
|
|
"text": "Start",
|
|
"class": "android.widget.Button",
|
|
"bounds": "[10,20][100,80]",
|
|
},
|
|
]
|
|
mock_ui_client.get_screen_data.return_value = SimpleNamespace(
|
|
base64=_VALID_PNG,
|
|
hierarchy_xml="<hierarchy />",
|
|
elements=expected_elements,
|
|
width=1080,
|
|
height=2400,
|
|
)
|
|
|
|
driver = AndroidAdbDriver(
|
|
device_id="emulator-5554",
|
|
adb_client=mock_adb_client,
|
|
ui_adb_client=mock_ui_client,
|
|
)
|
|
|
|
screen_data = await driver.get_screen_data(skip_settling=True)
|
|
|
|
assert screen_data.ui_elements == [expected_elements[1]]
|
|
assert screen_data.ui_hierarchy_xml == "<hierarchy />"
|
|
mock_ui_client.get_hierarchy.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("enabled", [True, False])
|
|
async def test_android_screenshot_limit_preserves_native_coordinates(enabled):
|
|
image = Image.new("RGB", (1200, 2600), "white")
|
|
buffer = BytesIO()
|
|
image.save(buffer, format="JPEG")
|
|
original = buffer.getvalue()
|
|
elements = [{"text": "目标", "bounds": "[100,200][300,400]"}]
|
|
ui_client = MagicMock()
|
|
ui_client.get_screen_data.return_value = SimpleNamespace(
|
|
base64=base64.b64encode(original).decode("ascii"),
|
|
hierarchy_xml="<hierarchy />",
|
|
elements=elements,
|
|
width=1200,
|
|
height=2600,
|
|
)
|
|
driver = AndroidAdbDriver("emulator-5554", MagicMock(), ui_adb_client=ui_client)
|
|
driver._screenshot_config = ScreenshotConfig(enabled=enabled, max_long_edge=2048)
|
|
|
|
screen = await driver.get_screen_data(skip_settling=True)
|
|
|
|
with Image.open(BytesIO(screen.screenshot_bytes)) as result:
|
|
assert result.size == ((945, 2048) if enabled else (1200, 2600))
|
|
assert screen.width == 1200 and screen.height == 2600
|
|
assert screen.ui_elements == elements
|
|
assert screen.screenshot_base64 == base64.b64encode(screen.screenshot_bytes).decode("ascii")
|
|
if not enabled:
|
|
assert screen.screenshot_bytes == original
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_android_driver_parses_xml_hierarchy_fallback():
|
|
"""XML-only clients remain supported without discarding their hierarchy."""
|
|
mock_adb_client = MagicMock()
|
|
mock_ui_client = MagicMock()
|
|
mock_ui_client.get_screen_data.return_value = SimpleNamespace(
|
|
base64=_VALID_PNG,
|
|
width=1080,
|
|
height=2400,
|
|
)
|
|
mock_ui_client.get_hierarchy.return_value = (
|
|
'<hierarchy><node text="Settings" class="android.widget.TextView" '
|
|
'bounds="[0,0][100,100]" /></hierarchy>'
|
|
)
|
|
|
|
driver = AndroidAdbDriver(
|
|
device_id="emulator-5554",
|
|
adb_client=mock_adb_client,
|
|
ui_adb_client=mock_ui_client,
|
|
)
|
|
|
|
screen_data = await driver.get_screen_data(skip_settling=True)
|
|
|
|
assert screen_data.ui_hierarchy_xml is not None
|
|
assert any(element.get("text") == "Settings" for element in screen_data.ui_elements)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_element_prefers_fresh_bounds_over_stale_center():
|
|
"""Layout transitions must not leave dynamic element taps at old coordinates."""
|
|
driver = MockDeviceDriver(device_id="test-mock", width=1080, height=2400)
|
|
driver.get_screen_data = AsyncMock(
|
|
return_value=ScreenData(
|
|
screenshot_bytes=base64.b64decode(_ONE_PIXEL_PNG),
|
|
screenshot_base64=_VALID_PNG,
|
|
ui_elements=[
|
|
{
|
|
"resource_id": "app:id/digit_4",
|
|
"bounds": "[10,1700][270,1940]",
|
|
"parsed_bounds": {
|
|
"left": 10,
|
|
"top": 1700,
|
|
"right": 270,
|
|
"bottom": 1940,
|
|
},
|
|
"center": [140, 1600],
|
|
}
|
|
],
|
|
width=1080,
|
|
height=2400,
|
|
)
|
|
)
|
|
|
|
_, center, error = await driver.find_element(resource_id="app:id/digit_4")
|
|
|
|
assert error is None
|
|
assert center == [140, 1820]
|