android-emu-agent 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- android_emu_agent/__init__.py +3 -0
- android_emu_agent/actions/__init__.py +1 -0
- android_emu_agent/actions/executor.py +288 -0
- android_emu_agent/actions/selector.py +122 -0
- android_emu_agent/actions/wait.py +193 -0
- android_emu_agent/artifacts/__init__.py +1 -0
- android_emu_agent/artifacts/manager.py +125 -0
- android_emu_agent/artifacts/py.typed +0 -0
- android_emu_agent/cli/__init__.py +1 -0
- android_emu_agent/cli/commands/__init__.py +1 -0
- android_emu_agent/cli/commands/action.py +158 -0
- android_emu_agent/cli/commands/app_cmd.py +95 -0
- android_emu_agent/cli/commands/artifact.py +81 -0
- android_emu_agent/cli/commands/daemon.py +62 -0
- android_emu_agent/cli/commands/device.py +122 -0
- android_emu_agent/cli/commands/emulator.py +46 -0
- android_emu_agent/cli/commands/file.py +139 -0
- android_emu_agent/cli/commands/reliability.py +310 -0
- android_emu_agent/cli/commands/session.py +65 -0
- android_emu_agent/cli/commands/ui.py +112 -0
- android_emu_agent/cli/commands/wait.py +132 -0
- android_emu_agent/cli/daemon_client.py +185 -0
- android_emu_agent/cli/main.py +52 -0
- android_emu_agent/cli/utils.py +171 -0
- android_emu_agent/daemon/__init__.py +1 -0
- android_emu_agent/daemon/core.py +62 -0
- android_emu_agent/daemon/health.py +177 -0
- android_emu_agent/daemon/models.py +244 -0
- android_emu_agent/daemon/server.py +1644 -0
- android_emu_agent/db/__init__.py +1 -0
- android_emu_agent/db/models.py +229 -0
- android_emu_agent/device/__init__.py +1 -0
- android_emu_agent/device/manager.py +522 -0
- android_emu_agent/device/session.py +129 -0
- android_emu_agent/errors.py +232 -0
- android_emu_agent/files/__init__.py +1 -0
- android_emu_agent/files/manager.py +311 -0
- android_emu_agent/py.typed +0 -0
- android_emu_agent/reliability/__init__.py +1 -0
- android_emu_agent/reliability/manager.py +244 -0
- android_emu_agent/ui/__init__.py +1 -0
- android_emu_agent/ui/context.py +169 -0
- android_emu_agent/ui/ref_resolver.py +149 -0
- android_emu_agent/ui/snapshotter.py +236 -0
- android_emu_agent/validation.py +59 -0
- android_emu_agent-0.1.3.dist-info/METADATA +375 -0
- android_emu_agent-0.1.3.dist-info/RECORD +50 -0
- android_emu_agent-0.1.3.dist-info/WHEEL +4 -0
- android_emu_agent-0.1.3.dist-info/entry_points.txt +2 -0
- android_emu_agent-0.1.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Actions - Execution of UI actions with retry and fallback logic."""
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Action executor - UI action execution with retries and fallbacks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
from android_emu_agent.errors import AgentError
|
|
14
|
+
from android_emu_agent.ui.ref_resolver import LocatorBundle
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import uiautomator2 as u2
|
|
18
|
+
|
|
19
|
+
logger = structlog.get_logger()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RetryPolicy:
|
|
24
|
+
"""Configurable retry behavior."""
|
|
25
|
+
|
|
26
|
+
max_attempts: int = 3
|
|
27
|
+
base_delay_ms: int = 300
|
|
28
|
+
backoff_multiplier: float = 2.0
|
|
29
|
+
max_delay_ms: int = 2000
|
|
30
|
+
|
|
31
|
+
def get_delay(self, attempt: int) -> int:
|
|
32
|
+
"""Calculate delay for attempt (0-indexed).
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
attempt: The attempt number (0 for first retry)
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Delay in milliseconds
|
|
39
|
+
"""
|
|
40
|
+
delay = self.base_delay_ms * (self.backoff_multiplier**attempt)
|
|
41
|
+
return min(int(delay), self.max_delay_ms)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ActionType(Enum):
|
|
45
|
+
"""Supported action types."""
|
|
46
|
+
|
|
47
|
+
TAP = "tap"
|
|
48
|
+
LONG_TAP = "long_tap"
|
|
49
|
+
DOUBLE_TAP = "double_tap"
|
|
50
|
+
SWIPE = "swipe"
|
|
51
|
+
SCROLL = "scroll"
|
|
52
|
+
SET_TEXT = "set_text"
|
|
53
|
+
CLEAR = "clear"
|
|
54
|
+
FOCUS = "focus"
|
|
55
|
+
BACK = "back"
|
|
56
|
+
HOME = "home"
|
|
57
|
+
RECENTS = "recents"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SwipeDirection(Enum):
|
|
61
|
+
"""Swipe direction for scroll/swipe actions."""
|
|
62
|
+
|
|
63
|
+
UP = "up"
|
|
64
|
+
DOWN = "down"
|
|
65
|
+
LEFT = "left"
|
|
66
|
+
RIGHT = "right"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ActionResult:
|
|
71
|
+
"""Result of an action execution."""
|
|
72
|
+
|
|
73
|
+
success: bool
|
|
74
|
+
elapsed_ms: float
|
|
75
|
+
error: AgentError | None = None
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict[str, Any]:
|
|
78
|
+
"""Convert to JSON response."""
|
|
79
|
+
if self.success:
|
|
80
|
+
return {"status": "done", "elapsed_ms": round(self.elapsed_ms, 2)}
|
|
81
|
+
return {
|
|
82
|
+
"status": "error",
|
|
83
|
+
"elapsed_ms": round(self.elapsed_ms, 2),
|
|
84
|
+
"error": {
|
|
85
|
+
"code": self.error.code if self.error else "UNKNOWN",
|
|
86
|
+
"message": self.error.message if self.error else "Unknown error",
|
|
87
|
+
"remediation": self.error.remediation if self.error else None,
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ActionExecutor:
|
|
93
|
+
"""Executes UI actions on device with retry logic."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, max_retries: int = 2, retry_delay: float = 0.3) -> None:
|
|
96
|
+
self.max_retries = max_retries
|
|
97
|
+
self.retry_delay = retry_delay
|
|
98
|
+
|
|
99
|
+
def _calculate_swipe_coords(
|
|
100
|
+
self,
|
|
101
|
+
bounds: list[int],
|
|
102
|
+
direction: SwipeDirection,
|
|
103
|
+
distance: float,
|
|
104
|
+
) -> tuple[tuple[int, int], tuple[int, int]]:
|
|
105
|
+
"""Calculate start and end coordinates for swipe.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
bounds: Container bounds [left, top, right, bottom]
|
|
109
|
+
direction: Swipe direction
|
|
110
|
+
distance: Fraction of container to swipe (0.0-1.0)
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Tuple of (start_coords, end_coords)
|
|
114
|
+
"""
|
|
115
|
+
left, top, right, bottom = bounds
|
|
116
|
+
cx = (left + right) // 2
|
|
117
|
+
cy = (top + bottom) // 2
|
|
118
|
+
width = right - left
|
|
119
|
+
height = bottom - top
|
|
120
|
+
|
|
121
|
+
offset_x = int(width * distance / 2)
|
|
122
|
+
offset_y = int(height * distance / 2)
|
|
123
|
+
|
|
124
|
+
if direction == SwipeDirection.UP:
|
|
125
|
+
return (cx, cy + offset_y), (cx, cy - offset_y)
|
|
126
|
+
elif direction == SwipeDirection.DOWN:
|
|
127
|
+
return (cx, cy - offset_y), (cx, cy + offset_y)
|
|
128
|
+
elif direction == SwipeDirection.LEFT:
|
|
129
|
+
return (cx + offset_x, cy), (cx - offset_x, cy)
|
|
130
|
+
else: # RIGHT
|
|
131
|
+
return (cx - offset_x, cy), (cx + offset_x, cy)
|
|
132
|
+
|
|
133
|
+
async def execute(
|
|
134
|
+
self,
|
|
135
|
+
device: u2.Device,
|
|
136
|
+
action: ActionType,
|
|
137
|
+
locator: LocatorBundle | None = None,
|
|
138
|
+
**kwargs: Any,
|
|
139
|
+
) -> ActionResult:
|
|
140
|
+
"""Execute an action on the device."""
|
|
141
|
+
start = time.time()
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
if action == ActionType.BACK:
|
|
145
|
+
await self._press_back(device)
|
|
146
|
+
elif action == ActionType.HOME:
|
|
147
|
+
await self._press_home(device)
|
|
148
|
+
elif action == ActionType.RECENTS:
|
|
149
|
+
await self._press_recents(device)
|
|
150
|
+
elif locator is None:
|
|
151
|
+
raise AgentError(
|
|
152
|
+
code="ERR_NO_LOCATOR",
|
|
153
|
+
message="Action requires a locator",
|
|
154
|
+
context={},
|
|
155
|
+
remediation="Provide a valid @ref or selector",
|
|
156
|
+
)
|
|
157
|
+
elif action == ActionType.TAP:
|
|
158
|
+
await self._tap(device, locator)
|
|
159
|
+
elif action == ActionType.LONG_TAP:
|
|
160
|
+
await self._long_tap(device, locator)
|
|
161
|
+
elif action == ActionType.SET_TEXT:
|
|
162
|
+
await self._set_text(device, locator, kwargs.get("text", ""))
|
|
163
|
+
elif action == ActionType.CLEAR:
|
|
164
|
+
await self._clear(device, locator)
|
|
165
|
+
else:
|
|
166
|
+
raise AgentError(
|
|
167
|
+
code="ERR_UNSUPPORTED_ACTION",
|
|
168
|
+
message=f"Action not implemented: {action.value}",
|
|
169
|
+
context={"action": action.value},
|
|
170
|
+
remediation="Use a supported action type",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
elapsed = (time.time() - start) * 1000
|
|
174
|
+
logger.info("action_executed", action=action.value, elapsed_ms=round(elapsed, 2))
|
|
175
|
+
return ActionResult(success=True, elapsed_ms=elapsed)
|
|
176
|
+
|
|
177
|
+
except AgentError as e:
|
|
178
|
+
elapsed = (time.time() - start) * 1000
|
|
179
|
+
return ActionResult(success=False, elapsed_ms=elapsed, error=e)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
elapsed = (time.time() - start) * 1000
|
|
182
|
+
logger.exception("action_failed", action=action.value)
|
|
183
|
+
return ActionResult(
|
|
184
|
+
success=False,
|
|
185
|
+
elapsed_ms=elapsed,
|
|
186
|
+
error=AgentError(
|
|
187
|
+
code="ERR_ACTION_FAILED",
|
|
188
|
+
message=str(e),
|
|
189
|
+
context={"action": action.value},
|
|
190
|
+
remediation="Take a new snapshot and retry",
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
async def _tap(self, device: u2.Device, locator: LocatorBundle) -> None:
|
|
195
|
+
"""Tap an element using locator strategies."""
|
|
196
|
+
element = await self._find_element(device, locator)
|
|
197
|
+
await asyncio.to_thread(element.click)
|
|
198
|
+
|
|
199
|
+
async def _long_tap(self, device: u2.Device, locator: LocatorBundle) -> None:
|
|
200
|
+
"""Long tap an element."""
|
|
201
|
+
element = await self._find_element(device, locator)
|
|
202
|
+
await asyncio.to_thread(element.long_click)
|
|
203
|
+
|
|
204
|
+
async def _set_text(self, device: u2.Device, locator: LocatorBundle, text: str) -> None:
|
|
205
|
+
"""Set text on an element."""
|
|
206
|
+
element = await self._find_element(device, locator)
|
|
207
|
+
await asyncio.to_thread(element.set_text, text)
|
|
208
|
+
|
|
209
|
+
async def _clear(self, device: u2.Device, locator: LocatorBundle) -> None:
|
|
210
|
+
"""Clear text from an element."""
|
|
211
|
+
element = await self._find_element(device, locator)
|
|
212
|
+
await asyncio.to_thread(element.clear_text)
|
|
213
|
+
|
|
214
|
+
async def _press_back(self, device: u2.Device) -> None:
|
|
215
|
+
"""Press back button."""
|
|
216
|
+
await asyncio.to_thread(device.press, "back")
|
|
217
|
+
|
|
218
|
+
async def _press_home(self, device: u2.Device) -> None:
|
|
219
|
+
"""Press home button."""
|
|
220
|
+
await asyncio.to_thread(device.press, "home")
|
|
221
|
+
|
|
222
|
+
async def _press_recents(self, device: u2.Device) -> None:
|
|
223
|
+
"""Press recents button."""
|
|
224
|
+
await asyncio.to_thread(device.press, "recent")
|
|
225
|
+
|
|
226
|
+
async def _find_element(self, device: u2.Device, locator: LocatorBundle) -> Any:
|
|
227
|
+
"""Find element using locator bundle strategies."""
|
|
228
|
+
# Strategy 1: resource-id (most reliable)
|
|
229
|
+
if locator.resource_id:
|
|
230
|
+
element = device(resourceId=locator.resource_id)
|
|
231
|
+
if await asyncio.to_thread(element.exists):
|
|
232
|
+
return element
|
|
233
|
+
|
|
234
|
+
# Strategy 2: content-desc
|
|
235
|
+
if locator.content_desc:
|
|
236
|
+
element = device(description=locator.content_desc)
|
|
237
|
+
if await asyncio.to_thread(element.exists):
|
|
238
|
+
return element
|
|
239
|
+
|
|
240
|
+
# Strategy 3: text match
|
|
241
|
+
if locator.text:
|
|
242
|
+
element = device(text=locator.text)
|
|
243
|
+
if await asyncio.to_thread(element.exists):
|
|
244
|
+
return element
|
|
245
|
+
|
|
246
|
+
# Strategy 4: bounds (coordinate fallback)
|
|
247
|
+
if locator.bounds and len(locator.bounds) == 4:
|
|
248
|
+
# Calculate center point
|
|
249
|
+
x = (locator.bounds[0] + locator.bounds[2]) // 2
|
|
250
|
+
y = (locator.bounds[1] + locator.bounds[3]) // 2
|
|
251
|
+
logger.warning("using_coordinate_fallback", ref=locator.ref, x=x, y=y)
|
|
252
|
+
# Return a coordinate-based "element" proxy
|
|
253
|
+
return _CoordinateProxy(device, x, y)
|
|
254
|
+
|
|
255
|
+
raise AgentError(
|
|
256
|
+
code="ERR_NOT_FOUND",
|
|
257
|
+
message=f"Element not found: {locator.ref}",
|
|
258
|
+
context={"ref": locator.ref, "locator": locator.to_dict()},
|
|
259
|
+
remediation="Take a new snapshot and use a fresh @ref",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class _CoordinateProxy:
|
|
264
|
+
"""Proxy for coordinate-based actions when element lookup fails."""
|
|
265
|
+
|
|
266
|
+
def __init__(self, device: u2.Device, x: int, y: int) -> None:
|
|
267
|
+
self._device = device
|
|
268
|
+
self._x = x
|
|
269
|
+
self._y = y
|
|
270
|
+
|
|
271
|
+
def click(self) -> None:
|
|
272
|
+
"""Click at coordinates."""
|
|
273
|
+
self._device.click(self._x, self._y)
|
|
274
|
+
|
|
275
|
+
def long_click(self) -> None:
|
|
276
|
+
"""Long click at coordinates."""
|
|
277
|
+
self._device.long_click(self._x, self._y)
|
|
278
|
+
|
|
279
|
+
def set_text(self, text: str) -> None:
|
|
280
|
+
"""Click then send text."""
|
|
281
|
+
self._device.click(self._x, self._y)
|
|
282
|
+
self._device.send_keys(text)
|
|
283
|
+
|
|
284
|
+
def clear_text(self) -> None:
|
|
285
|
+
"""Click then clear (select all + delete)."""
|
|
286
|
+
self._device.click(self._x, self._y)
|
|
287
|
+
self._device.send_action("selectAll")
|
|
288
|
+
self._device.send_keys("")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Selector types and parser for escape hatch actions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from android_emu_agent.errors import invalid_selector_error
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Selector(ABC):
|
|
13
|
+
"""Base class for element selectors."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def to_u2_kwargs(self) -> dict[str, Any]:
|
|
17
|
+
"""Convert to uiautomator2 selector kwargs."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class RefSelector(Selector):
|
|
23
|
+
"""Selector for @ref syntax."""
|
|
24
|
+
|
|
25
|
+
ref: str
|
|
26
|
+
|
|
27
|
+
def to_u2_kwargs(self) -> dict[str, Any]:
|
|
28
|
+
"""RefSelector returns empty kwargs (resolved elsewhere)."""
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class TextSelector(Selector):
|
|
34
|
+
"""Selector for text: syntax."""
|
|
35
|
+
|
|
36
|
+
text: str
|
|
37
|
+
|
|
38
|
+
def to_u2_kwargs(self) -> dict[str, Any]:
|
|
39
|
+
"""Return uiautomator2 text selector kwargs."""
|
|
40
|
+
return {"text": self.text}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ResourceIdSelector(Selector):
|
|
45
|
+
"""Selector for id: syntax."""
|
|
46
|
+
|
|
47
|
+
resource_id: str
|
|
48
|
+
|
|
49
|
+
def to_u2_kwargs(self) -> dict[str, Any]:
|
|
50
|
+
"""Return uiautomator2 resourceId selector kwargs."""
|
|
51
|
+
return {"resourceId": self.resource_id}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class DescSelector(Selector):
|
|
56
|
+
"""Selector for desc: syntax."""
|
|
57
|
+
|
|
58
|
+
desc: str
|
|
59
|
+
|
|
60
|
+
def to_u2_kwargs(self) -> dict[str, Any]:
|
|
61
|
+
"""Return uiautomator2 description selector kwargs."""
|
|
62
|
+
return {"description": self.desc}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class CoordsSelector(Selector):
|
|
67
|
+
"""Selector for coords: syntax."""
|
|
68
|
+
|
|
69
|
+
x: int
|
|
70
|
+
y: int
|
|
71
|
+
|
|
72
|
+
def to_u2_kwargs(self) -> dict[str, Any]:
|
|
73
|
+
"""CoordsSelector returns empty kwargs (handled specially)."""
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_selector(target: str) -> Selector:
|
|
78
|
+
"""
|
|
79
|
+
Parse escape hatch selector or ref.
|
|
80
|
+
|
|
81
|
+
Supported formats:
|
|
82
|
+
- @ref (e.g., @a1, @b5) - RefSelector
|
|
83
|
+
- text:"..." or text:'...' or text:value - TextSelector
|
|
84
|
+
- id:resource_id - ResourceIdSelector
|
|
85
|
+
- desc:"..." or desc:'...' or desc:value - DescSelector
|
|
86
|
+
- coords:x,y - CoordsSelector
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
target: The selector string to parse.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Parsed Selector instance.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
AgentError: If selector format is invalid (ERR_INVALID_SELECTOR).
|
|
96
|
+
"""
|
|
97
|
+
if not target:
|
|
98
|
+
raise invalid_selector_error(target)
|
|
99
|
+
|
|
100
|
+
if target.startswith("@"):
|
|
101
|
+
return RefSelector(ref=target)
|
|
102
|
+
|
|
103
|
+
if target.startswith("text:"):
|
|
104
|
+
text = target[5:].strip('"').strip("'")
|
|
105
|
+
return TextSelector(text=text)
|
|
106
|
+
|
|
107
|
+
if target.startswith("id:"):
|
|
108
|
+
return ResourceIdSelector(resource_id=target[3:])
|
|
109
|
+
|
|
110
|
+
if target.startswith("desc:"):
|
|
111
|
+
desc = target[5:].strip('"').strip("'")
|
|
112
|
+
return DescSelector(desc=desc)
|
|
113
|
+
|
|
114
|
+
if target.startswith("coords:"):
|
|
115
|
+
try:
|
|
116
|
+
coords_str = target[7:]
|
|
117
|
+
x_str, y_str = coords_str.split(",")
|
|
118
|
+
return CoordsSelector(x=int(x_str), y=int(y_str))
|
|
119
|
+
except (ValueError, IndexError):
|
|
120
|
+
raise invalid_selector_error(target) from None
|
|
121
|
+
|
|
122
|
+
raise invalid_selector_error(target)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Wait engine - Predicates over context and snapshot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
import structlog
|
|
13
|
+
|
|
14
|
+
from android_emu_agent.errors import AgentError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import uiautomator2 as u2
|
|
18
|
+
|
|
19
|
+
logger = structlog.get_logger()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WaitCondition(Enum):
|
|
23
|
+
"""Wait condition types."""
|
|
24
|
+
|
|
25
|
+
IDLE = "idle"
|
|
26
|
+
ACTIVITY = "activity"
|
|
27
|
+
EXISTS = "exists"
|
|
28
|
+
GONE = "gone"
|
|
29
|
+
TEXT = "text"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class WaitResult:
|
|
34
|
+
"""Result of a wait operation."""
|
|
35
|
+
|
|
36
|
+
success: bool
|
|
37
|
+
elapsed_ms: float
|
|
38
|
+
error: AgentError | None = None
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict[str, Any]:
|
|
41
|
+
"""Convert to JSON response."""
|
|
42
|
+
if self.success:
|
|
43
|
+
return {"status": "done", "elapsed_ms": round(self.elapsed_ms, 2)}
|
|
44
|
+
return {
|
|
45
|
+
"status": "timeout",
|
|
46
|
+
"elapsed_ms": round(self.elapsed_ms, 2),
|
|
47
|
+
"error": {
|
|
48
|
+
"code": self.error.code if self.error else "ERR_TIMEOUT",
|
|
49
|
+
"message": self.error.message if self.error else "Wait timed out",
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class WaitEngine:
|
|
55
|
+
"""Wait for conditions on device state."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, default_timeout: float = 10.0, poll_interval: float = 0.5) -> None:
|
|
58
|
+
self.default_timeout = default_timeout
|
|
59
|
+
self.poll_interval = poll_interval
|
|
60
|
+
|
|
61
|
+
async def wait_idle(
|
|
62
|
+
self,
|
|
63
|
+
device: u2.Device,
|
|
64
|
+
timeout: float | None = None,
|
|
65
|
+
) -> WaitResult:
|
|
66
|
+
"""Wait for UI to become idle."""
|
|
67
|
+
timeout = timeout or self.default_timeout
|
|
68
|
+
start = time.time()
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
wait_fn = getattr(device, "wait_idle", None)
|
|
72
|
+
if wait_fn is None:
|
|
73
|
+
wait_fn = getattr(device, "wait_activity", None)
|
|
74
|
+
if wait_fn is None:
|
|
75
|
+
await asyncio.sleep(timeout)
|
|
76
|
+
else:
|
|
77
|
+
await asyncio.to_thread(wait_fn, timeout=timeout)
|
|
78
|
+
elapsed = (time.time() - start) * 1000
|
|
79
|
+
return WaitResult(success=True, elapsed_ms=elapsed)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
elapsed = (time.time() - start) * 1000
|
|
82
|
+
return WaitResult(
|
|
83
|
+
success=False,
|
|
84
|
+
elapsed_ms=elapsed,
|
|
85
|
+
error=AgentError(
|
|
86
|
+
code="ERR_TIMEOUT",
|
|
87
|
+
message=f"Wait idle timed out: {e}",
|
|
88
|
+
context={},
|
|
89
|
+
remediation="UI may still be loading; try again or check for dialogs",
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def wait_activity(
|
|
94
|
+
self,
|
|
95
|
+
device: u2.Device,
|
|
96
|
+
activity_pattern: str,
|
|
97
|
+
timeout: float | None = None,
|
|
98
|
+
) -> WaitResult:
|
|
99
|
+
"""Wait for a specific activity to appear."""
|
|
100
|
+
return await self._poll_condition(
|
|
101
|
+
predicate=lambda: self._check_activity(device, activity_pattern),
|
|
102
|
+
timeout=timeout,
|
|
103
|
+
error_message=f"Activity '{activity_pattern}' not found",
|
|
104
|
+
remediation="Check activity name or wait longer",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
async def wait_exists(
|
|
108
|
+
self,
|
|
109
|
+
device: u2.Device,
|
|
110
|
+
selector: dict[str, str],
|
|
111
|
+
timeout: float | None = None,
|
|
112
|
+
) -> WaitResult:
|
|
113
|
+
"""Wait for an element to exist."""
|
|
114
|
+
return await self._poll_condition(
|
|
115
|
+
predicate=lambda: self._check_exists(device, selector),
|
|
116
|
+
timeout=timeout,
|
|
117
|
+
error_message=f"Element not found: {selector}",
|
|
118
|
+
remediation="Element may not appear; check selector",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def wait_gone(
|
|
122
|
+
self,
|
|
123
|
+
device: u2.Device,
|
|
124
|
+
selector: dict[str, str],
|
|
125
|
+
timeout: float | None = None,
|
|
126
|
+
) -> WaitResult:
|
|
127
|
+
"""Wait for an element to disappear."""
|
|
128
|
+
return await self._poll_condition(
|
|
129
|
+
predicate=lambda: not self._check_exists(device, selector),
|
|
130
|
+
timeout=timeout,
|
|
131
|
+
error_message=f"Element still present: {selector}",
|
|
132
|
+
remediation="Element may be persistent; try different approach",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async def wait_text(
|
|
136
|
+
self,
|
|
137
|
+
device: u2.Device,
|
|
138
|
+
text: str,
|
|
139
|
+
timeout: float | None = None,
|
|
140
|
+
) -> WaitResult:
|
|
141
|
+
"""Wait for text to appear on screen."""
|
|
142
|
+
return await self._poll_condition(
|
|
143
|
+
predicate=lambda: self._check_text(device, text),
|
|
144
|
+
timeout=timeout,
|
|
145
|
+
error_message=f"Text not found: '{text}'",
|
|
146
|
+
remediation="Text may not appear; check for typos or case sensitivity",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
async def _poll_condition(
|
|
150
|
+
self,
|
|
151
|
+
predicate: Callable[[], bool],
|
|
152
|
+
timeout: float | None,
|
|
153
|
+
error_message: str,
|
|
154
|
+
remediation: str,
|
|
155
|
+
) -> WaitResult:
|
|
156
|
+
"""Poll a condition until timeout."""
|
|
157
|
+
timeout = timeout or self.default_timeout
|
|
158
|
+
start = time.time()
|
|
159
|
+
|
|
160
|
+
while time.time() - start < timeout:
|
|
161
|
+
try:
|
|
162
|
+
if await asyncio.to_thread(predicate):
|
|
163
|
+
elapsed = (time.time() - start) * 1000
|
|
164
|
+
return WaitResult(success=True, elapsed_ms=elapsed)
|
|
165
|
+
except Exception:
|
|
166
|
+
pass # Ignore errors during polling
|
|
167
|
+
await asyncio.sleep(self.poll_interval)
|
|
168
|
+
|
|
169
|
+
elapsed = (time.time() - start) * 1000
|
|
170
|
+
return WaitResult(
|
|
171
|
+
success=False,
|
|
172
|
+
elapsed_ms=elapsed,
|
|
173
|
+
error=AgentError(
|
|
174
|
+
code="ERR_TIMEOUT",
|
|
175
|
+
message=error_message,
|
|
176
|
+
context={"timeout_ms": timeout * 1000},
|
|
177
|
+
remediation=remediation,
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _check_activity(self, device: u2.Device, pattern: str) -> bool:
|
|
182
|
+
"""Check if current activity matches pattern."""
|
|
183
|
+
info = device.app_current()
|
|
184
|
+
current = info.get("activity", "")
|
|
185
|
+
return pattern in current
|
|
186
|
+
|
|
187
|
+
def _check_exists(self, device: u2.Device, selector: dict[str, str]) -> bool:
|
|
188
|
+
"""Check if element exists."""
|
|
189
|
+
return bool(device(**selector).exists())
|
|
190
|
+
|
|
191
|
+
def _check_text(self, device: u2.Device, text: str) -> bool:
|
|
192
|
+
"""Check if text exists on screen."""
|
|
193
|
+
return bool(device(text=text).exists()) or bool(device(textContains=text).exists())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Artifacts - Screenshots, logs, debug bundles."""
|