Files
Artemis-Eapil/tests/unit/clients/test_ui_automator_startup.py
T
chenmingxuan be6e8c2abd 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.
2026-10-27 01:13:41 +08:00

299 lines
11 KiB
Python

"""UIAutomator startup recovery, using the real library with isolated device IO."""
from dataclasses import dataclass, field
import json
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from uiautomator2 import base, core
from artemis.clients import ui_automator_client
from artemis.clients.accessibility_client import HelperUnavailable
from artemis.clients.screen_client_factory import FallbackScreenClient
from artemis.runtime import observation_budget
@dataclass
class Clock:
now: float = 0.0
def time(self):
return self.now
def sleep(self, seconds):
self.now += seconds
class DeviceSocket:
def __init__(self, mode):
self.mode = mode
self.closed = threading.Event()
self.started = threading.Event()
def recv(self, _size):
if not self.started.is_set():
self.started.set()
output = b"[server] INFO: [UiAutomator2Server] Starting Server\n"
if self.mode == "conflict":
output += (
b"java.lang.IllegalStateException: UiAutomationService already registered!"
)
return output
self.closed.wait()
return b""
class DeviceConnection:
def __init__(self, mode):
self.conn = DeviceSocket(mode)
self.close_calls = 0
self.close_error = None
def close(self):
self.close_calls += 1
if self.close_error:
raise self.close_error
self.conn.closed.set()
@dataclass
class DeviceServer:
modes: list[str] = field(default_factory=lambda: ["not_ready"])
connections: list[DeviceConnection] = field(default_factory=list)
previous_closed_at_launch: list[bool] = field(default_factory=list)
clock: Clock = field(default_factory=Clock)
cleanup_callbacks: MagicMock = field(default_factory=MagicMock)
close_error: Exception | None = None
probe_error: BaseException | None = None
def shell(self, command, stream=False):
assert stream is True
assert "com.wetest.uia2.Main" in command
self.previous_closed_at_launch.append(all(c.conn.closed.is_set() for c in self.connections))
index = min(len(self.connections), len(self.modes) - 1)
connection = DeviceConnection(self.modes[index])
connection.close_error = self.close_error
self.connections.append(connection)
return connection
def http_request(self, _device, _port, method, path, *args, **kwargs):
if self.connections:
latest = self.connections[-1]
assert latest.conn.started.wait(timeout=2)
if self.probe_error is not None:
raise self.probe_error
if latest.conn.mode == "ready" and not latest.conn.closed.is_set():
if method == "POST":
return core.HTTPResponse(
json.dumps({"result": {"displayWidth": 1080}}).encode()
)
return core.HTTPResponse(b"pong")
raise core.HTTPError("simulated device server is not ready")
@pytest.fixture
def server(monkeypatch):
simulated = DeviceServer()
device = SimpleNamespace(serial="isolated-startup-device", shell=simulated.shell)
monkeypatch.setattr(base._BaseClient, "_wait_for_device", lambda _self: device)
monkeypatch.setattr(core.BasicUiautomatorServer, "_setup_jar", lambda _self: None)
monkeypatch.setattr(core, "_http_request", simulated.http_request)
monkeypatch.setattr(core, "time", simulated.clock)
monkeypatch.setattr(core, "atexit", SimpleNamespace(register=simulated.cleanup_callbacks))
monkeypatch.setattr(ui_automator_client, "time", simulated.clock)
monkeypatch.setattr(ui_automator_client, "_ensure_maestro_not_installed", lambda _s: None)
monkeypatch.setattr(ui_automator_client, "ensure_device_awake", lambda _s: "simulated")
yield simulated
# Close fake sockets even when a regression leaves a real library reader alive.
for connection in simulated.connections:
connection.conn.closed.set()
def test_each_failed_launch_is_closed_before_retry_and_final_failure(server):
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="server not ready") as caught:
client.connect()
assert len(server.connections) == 3
assert server.previous_closed_at_launch == [True, True, True]
assert [c.close_calls for c in server.connections] == [1, 1, 1]
assert server.clock.now == 93.0
assert server.cleanup_callbacks.call_count == 0
assert client._device is None
assert client._awake_strategy is None
assert isinstance(caught.value.__cause__, core.LaunchUiAutomationError)
assert "isolated-startup-device" in str(caught.value)
assert "3" in str(caught.value)
@pytest.mark.parametrize("helper_seconds", [0, 18, 59])
def test_helper_and_uiautomator_share_observation_deadline(server, monkeypatch, helper_seconds):
monkeypatch.setattr(observation_budget, "monotonic", server.clock.time)
helper = MagicMock()
def unavailable():
server.clock.sleep(helper_seconds)
raise HelperUnavailable("helper snapshot timed out")
helper.get_screen_data.side_effect = unavailable
client = FallbackScreenClient(
"isolated-startup-device",
helper=helper,
state_probe=lambda _serial: "device",
)
budget = observation_budget.ObservationBudget(deadline=60)
token = observation_budget.current_observation.set(budget)
try:
with pytest.raises(TimeoutError, match="预算耗尽"):
client.get_screen_data()
finally:
observation_budget.current_observation.reset(token)
client.disconnect()
assert server.clock.now == 60
assert len(server.connections) <= 2
assert all(c.close_calls == 1 for c in server.connections)
assert client.active_backend is None
def test_disconnect_during_connect_discards_and_stops_late_device(server, monkeypatch):
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
candidate = MagicMock()
def late_connection(_device_id):
client.disconnect(stop_server=True)
return candidate
factory = MagicMock(side_effect=late_connection)
monkeypatch.setattr(ui_automator_client, "_ManagedUIAutomatorDevice", factory)
with pytest.raises(RuntimeError, match="连接已取消"):
client.connect()
factory.assert_called_once()
candidate.stop_uiautomator.assert_called_once()
assert client._device is None
assert client._awake_strategy is None
def test_retry_can_connect_after_failed_launch_was_stopped(server):
server.modes = ["not_ready", "ready"]
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
client.connect()
client.connect() # Cached device info also uses the real library RPC path.
assert len(server.connections) == 2
assert server.previous_closed_at_launch == [True, True]
assert server.connections[0].close_calls == 1
assert server.connections[1].close_calls == 0
assert server.cleanup_callbacks.call_count == 1
client.disconnect(stop_server=True)
assert server.connections[1].close_calls == 1
def test_later_connection_can_succeed_after_all_previous_attempts_failed(server):
server.modes = ["not_ready", "not_ready", "not_ready", "ready"]
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="server not ready"):
client.connect()
client.connect()
assert len(server.connections) == 4
assert server.previous_closed_at_launch == [True, True, True, True]
assert [c.close_calls for c in server.connections] == [1, 1, 1, 0]
client.disconnect(stop_server=True)
assert server.connections[-1].close_calls == 1
def test_auto_fallback_cleans_failed_launches_before_next_attempt(server):
helper = MagicMock()
helper.connect.side_effect = HelperUnavailable("simulated helper unavailable")
client = FallbackScreenClient(
"isolated-startup-device",
helper=helper,
uiautomator_factory=lambda: ui_automator_client.UIAutomatorClient(
"isolated-startup-device"
),
state_probe=lambda _serial: "device",
)
with pytest.raises(RuntimeError, match="server not ready"):
client.connect()
client.disconnect()
assert len(server.connections) == 3
assert server.previous_closed_at_launch == [True, True, True]
assert [c.close_calls for c in server.connections] == [1, 1, 1]
helper.disconnect.assert_called_once()
def test_service_conflict_closes_own_streams_and_preserves_cause(server):
server.modes = ["conflict"]
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="already registered") as caught:
client.connect()
assert isinstance(caught.value.__cause__, core.AccessibilityServiceAlreadyRegisteredError)
assert len(server.connections) == 3
assert [c.close_calls for c in server.connections] == [1, 1, 1]
def test_cleanup_error_does_not_replace_original_startup_failure(server):
server.close_error = OSError("simulated cleanup connection error")
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="server not ready") as caught:
client.connect()
assert isinstance(caught.value.__cause__, core.LaunchUiAutomationError)
assert [c.close_calls for c in server.connections] == [1, 1, 1]
def test_interrupt_during_startup_also_closes_own_stream(server):
server.probe_error = KeyboardInterrupt()
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(KeyboardInterrupt):
client.connect()
assert len(server.connections) == 1
assert server.connections[0].close_calls == 1
def test_failure_before_launch_does_not_stop_another_device_service(server, monkeypatch):
failure = OSError("simulated jar transfer failure")
def fail_before_launch(_self):
raise failure
monkeypatch.setattr(core.BasicUiautomatorServer, "_setup_jar", fail_before_launch)
stop = MagicMock()
monkeypatch.setattr(core.BasicUiautomatorServer, "stop_uiautomator", stop)
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="jar transfer failure") as caught:
client.connect()
assert caught.value.__cause__ is failure
assert server.connections == []
stop.assert_not_called()
def test_default_helper_success_does_not_start_uiautomator(server):
factory = MagicMock()
client = FallbackScreenClient(
"isolated-startup-device",
helper=MagicMock(),
uiautomator_factory=factory,
state_probe=lambda _serial: "device",
)
client.connect()
client.disconnect()
factory.assert_not_called()
assert server.connections == []