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.
Files changed (50) hide show
  1. android_emu_agent/__init__.py +3 -0
  2. android_emu_agent/actions/__init__.py +1 -0
  3. android_emu_agent/actions/executor.py +288 -0
  4. android_emu_agent/actions/selector.py +122 -0
  5. android_emu_agent/actions/wait.py +193 -0
  6. android_emu_agent/artifacts/__init__.py +1 -0
  7. android_emu_agent/artifacts/manager.py +125 -0
  8. android_emu_agent/artifacts/py.typed +0 -0
  9. android_emu_agent/cli/__init__.py +1 -0
  10. android_emu_agent/cli/commands/__init__.py +1 -0
  11. android_emu_agent/cli/commands/action.py +158 -0
  12. android_emu_agent/cli/commands/app_cmd.py +95 -0
  13. android_emu_agent/cli/commands/artifact.py +81 -0
  14. android_emu_agent/cli/commands/daemon.py +62 -0
  15. android_emu_agent/cli/commands/device.py +122 -0
  16. android_emu_agent/cli/commands/emulator.py +46 -0
  17. android_emu_agent/cli/commands/file.py +139 -0
  18. android_emu_agent/cli/commands/reliability.py +310 -0
  19. android_emu_agent/cli/commands/session.py +65 -0
  20. android_emu_agent/cli/commands/ui.py +112 -0
  21. android_emu_agent/cli/commands/wait.py +132 -0
  22. android_emu_agent/cli/daemon_client.py +185 -0
  23. android_emu_agent/cli/main.py +52 -0
  24. android_emu_agent/cli/utils.py +171 -0
  25. android_emu_agent/daemon/__init__.py +1 -0
  26. android_emu_agent/daemon/core.py +62 -0
  27. android_emu_agent/daemon/health.py +177 -0
  28. android_emu_agent/daemon/models.py +244 -0
  29. android_emu_agent/daemon/server.py +1644 -0
  30. android_emu_agent/db/__init__.py +1 -0
  31. android_emu_agent/db/models.py +229 -0
  32. android_emu_agent/device/__init__.py +1 -0
  33. android_emu_agent/device/manager.py +522 -0
  34. android_emu_agent/device/session.py +129 -0
  35. android_emu_agent/errors.py +232 -0
  36. android_emu_agent/files/__init__.py +1 -0
  37. android_emu_agent/files/manager.py +311 -0
  38. android_emu_agent/py.typed +0 -0
  39. android_emu_agent/reliability/__init__.py +1 -0
  40. android_emu_agent/reliability/manager.py +244 -0
  41. android_emu_agent/ui/__init__.py +1 -0
  42. android_emu_agent/ui/context.py +169 -0
  43. android_emu_agent/ui/ref_resolver.py +149 -0
  44. android_emu_agent/ui/snapshotter.py +236 -0
  45. android_emu_agent/validation.py +59 -0
  46. android_emu_agent-0.1.3.dist-info/METADATA +375 -0
  47. android_emu_agent-0.1.3.dist-info/RECORD +50 -0
  48. android_emu_agent-0.1.3.dist-info/WHEEL +4 -0
  49. android_emu_agent-0.1.3.dist-info/entry_points.txt +2 -0
  50. android_emu_agent-0.1.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """Android Emu Agent - LLM-driven Android UI control system."""
2
+
3
+ __version__ = "0.1.3"
@@ -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."""