251 lines
9.1 KiB
Python
251 lines
9.1 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
|
|
|
|
|
|
@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)
|
|
|
|
|
|
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 == []
|