minitap-mobile-use 3.3.0__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 (115) hide show
  1. minitap/mobile_use/__init__.py +0 -0
  2. minitap/mobile_use/agents/contextor/contextor.md +55 -0
  3. minitap/mobile_use/agents/contextor/contextor.py +175 -0
  4. minitap/mobile_use/agents/contextor/types.py +36 -0
  5. minitap/mobile_use/agents/cortex/cortex.md +135 -0
  6. minitap/mobile_use/agents/cortex/cortex.py +152 -0
  7. minitap/mobile_use/agents/cortex/types.py +15 -0
  8. minitap/mobile_use/agents/executor/executor.md +42 -0
  9. minitap/mobile_use/agents/executor/executor.py +87 -0
  10. minitap/mobile_use/agents/executor/tool_node.py +152 -0
  11. minitap/mobile_use/agents/hopper/hopper.md +15 -0
  12. minitap/mobile_use/agents/hopper/hopper.py +44 -0
  13. minitap/mobile_use/agents/orchestrator/human.md +12 -0
  14. minitap/mobile_use/agents/orchestrator/orchestrator.md +21 -0
  15. minitap/mobile_use/agents/orchestrator/orchestrator.py +134 -0
  16. minitap/mobile_use/agents/orchestrator/types.py +11 -0
  17. minitap/mobile_use/agents/outputter/human.md +25 -0
  18. minitap/mobile_use/agents/outputter/outputter.py +85 -0
  19. minitap/mobile_use/agents/outputter/test_outputter.py +167 -0
  20. minitap/mobile_use/agents/planner/human.md +14 -0
  21. minitap/mobile_use/agents/planner/planner.md +126 -0
  22. minitap/mobile_use/agents/planner/planner.py +101 -0
  23. minitap/mobile_use/agents/planner/types.py +51 -0
  24. minitap/mobile_use/agents/planner/utils.py +70 -0
  25. minitap/mobile_use/agents/summarizer/summarizer.py +35 -0
  26. minitap/mobile_use/agents/video_analyzer/__init__.py +5 -0
  27. minitap/mobile_use/agents/video_analyzer/human.md +5 -0
  28. minitap/mobile_use/agents/video_analyzer/video_analyzer.md +37 -0
  29. minitap/mobile_use/agents/video_analyzer/video_analyzer.py +111 -0
  30. minitap/mobile_use/clients/browserstack_client.py +477 -0
  31. minitap/mobile_use/clients/idb_client.py +429 -0
  32. minitap/mobile_use/clients/ios_client.py +332 -0
  33. minitap/mobile_use/clients/ios_client_config.py +141 -0
  34. minitap/mobile_use/clients/ui_automator_client.py +330 -0
  35. minitap/mobile_use/clients/wda_client.py +526 -0
  36. minitap/mobile_use/clients/wda_lifecycle.py +367 -0
  37. minitap/mobile_use/config.py +413 -0
  38. minitap/mobile_use/constants.py +3 -0
  39. minitap/mobile_use/context.py +106 -0
  40. minitap/mobile_use/controllers/__init__.py +0 -0
  41. minitap/mobile_use/controllers/android_controller.py +524 -0
  42. minitap/mobile_use/controllers/controller_factory.py +46 -0
  43. minitap/mobile_use/controllers/device_controller.py +182 -0
  44. minitap/mobile_use/controllers/ios_controller.py +436 -0
  45. minitap/mobile_use/controllers/platform_specific_commands_controller.py +199 -0
  46. minitap/mobile_use/controllers/types.py +106 -0
  47. minitap/mobile_use/controllers/unified_controller.py +193 -0
  48. minitap/mobile_use/graph/graph.py +160 -0
  49. minitap/mobile_use/graph/state.py +115 -0
  50. minitap/mobile_use/main.py +309 -0
  51. minitap/mobile_use/sdk/__init__.py +12 -0
  52. minitap/mobile_use/sdk/agent.py +1294 -0
  53. minitap/mobile_use/sdk/builders/__init__.py +10 -0
  54. minitap/mobile_use/sdk/builders/agent_config_builder.py +307 -0
  55. minitap/mobile_use/sdk/builders/index.py +15 -0
  56. minitap/mobile_use/sdk/builders/task_request_builder.py +236 -0
  57. minitap/mobile_use/sdk/constants.py +1 -0
  58. minitap/mobile_use/sdk/examples/README.md +83 -0
  59. minitap/mobile_use/sdk/examples/__init__.py +1 -0
  60. minitap/mobile_use/sdk/examples/app_lock_messaging.py +54 -0
  61. minitap/mobile_use/sdk/examples/platform_manual_task_example.py +67 -0
  62. minitap/mobile_use/sdk/examples/platform_minimal_example.py +48 -0
  63. minitap/mobile_use/sdk/examples/simple_photo_organizer.py +76 -0
  64. minitap/mobile_use/sdk/examples/smart_notification_assistant.py +225 -0
  65. minitap/mobile_use/sdk/examples/video_transcription_example.py +117 -0
  66. minitap/mobile_use/sdk/services/cloud_mobile.py +656 -0
  67. minitap/mobile_use/sdk/services/platform.py +434 -0
  68. minitap/mobile_use/sdk/types/__init__.py +51 -0
  69. minitap/mobile_use/sdk/types/agent.py +84 -0
  70. minitap/mobile_use/sdk/types/exceptions.py +138 -0
  71. minitap/mobile_use/sdk/types/platform.py +183 -0
  72. minitap/mobile_use/sdk/types/task.py +269 -0
  73. minitap/mobile_use/sdk/utils.py +29 -0
  74. minitap/mobile_use/services/accessibility.py +100 -0
  75. minitap/mobile_use/services/llm.py +247 -0
  76. minitap/mobile_use/services/telemetry.py +421 -0
  77. minitap/mobile_use/tools/index.py +67 -0
  78. minitap/mobile_use/tools/mobile/back.py +52 -0
  79. minitap/mobile_use/tools/mobile/erase_one_char.py +56 -0
  80. minitap/mobile_use/tools/mobile/focus_and_clear_text.py +317 -0
  81. minitap/mobile_use/tools/mobile/focus_and_input_text.py +153 -0
  82. minitap/mobile_use/tools/mobile/launch_app.py +86 -0
  83. minitap/mobile_use/tools/mobile/long_press_on.py +169 -0
  84. minitap/mobile_use/tools/mobile/open_link.py +62 -0
  85. minitap/mobile_use/tools/mobile/press_key.py +83 -0
  86. minitap/mobile_use/tools/mobile/stop_app.py +62 -0
  87. minitap/mobile_use/tools/mobile/swipe.py +156 -0
  88. minitap/mobile_use/tools/mobile/tap.py +154 -0
  89. minitap/mobile_use/tools/mobile/video_recording.py +177 -0
  90. minitap/mobile_use/tools/mobile/wait_for_delay.py +81 -0
  91. minitap/mobile_use/tools/scratchpad.py +147 -0
  92. minitap/mobile_use/tools/test_utils.py +413 -0
  93. minitap/mobile_use/tools/tool_wrapper.py +16 -0
  94. minitap/mobile_use/tools/types.py +35 -0
  95. minitap/mobile_use/tools/utils.py +336 -0
  96. minitap/mobile_use/utils/app_launch_utils.py +173 -0
  97. minitap/mobile_use/utils/cli_helpers.py +37 -0
  98. minitap/mobile_use/utils/cli_selection.py +143 -0
  99. minitap/mobile_use/utils/conversations.py +31 -0
  100. minitap/mobile_use/utils/decorators.py +124 -0
  101. minitap/mobile_use/utils/errors.py +6 -0
  102. minitap/mobile_use/utils/file.py +13 -0
  103. minitap/mobile_use/utils/logger.py +183 -0
  104. minitap/mobile_use/utils/media.py +186 -0
  105. minitap/mobile_use/utils/recorder.py +52 -0
  106. minitap/mobile_use/utils/requests_utils.py +37 -0
  107. minitap/mobile_use/utils/shell_utils.py +20 -0
  108. minitap/mobile_use/utils/test_ui_hierarchy.py +178 -0
  109. minitap/mobile_use/utils/time.py +6 -0
  110. minitap/mobile_use/utils/ui_hierarchy.py +132 -0
  111. minitap/mobile_use/utils/video.py +281 -0
  112. minitap_mobile_use-3.3.0.dist-info/METADATA +329 -0
  113. minitap_mobile_use-3.3.0.dist-info/RECORD +115 -0
  114. minitap_mobile_use-3.3.0.dist-info/WHEEL +4 -0
  115. minitap_mobile_use-3.3.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,413 @@
1
+ import asyncio
2
+ import sys
3
+ from unittest.mock import Mock, patch
4
+
5
+ import pytest
6
+
7
+ # Mock the problematic langgraph import at module level
8
+ sys.modules["langgraph.prebuilt.chat_agent_executor"] = Mock()
9
+ sys.modules["minitap.mobile_use.graph.state"] = Mock()
10
+
11
+ from minitap.mobile_use.context import DeviceContext, DevicePlatform, MobileUseContext # noqa: E402
12
+ from minitap.mobile_use.tools.types import Target # noqa: E402
13
+ from minitap.mobile_use.tools.utils import ( # noqa: E402
14
+ IdSelectorRequest,
15
+ SelectorRequestWithCoordinates,
16
+ focus_element_if_needed,
17
+ move_cursor_to_end_if_bounds,
18
+ )
19
+ from minitap.mobile_use.utils.ui_hierarchy import ElementBounds # noqa: E402
20
+
21
+
22
+ @pytest.fixture
23
+ def mock_context():
24
+ """Create a mock MobileUseContext for testing."""
25
+ ctx = Mock(spec=MobileUseContext)
26
+
27
+ # Create device context with necessary attributes
28
+ ctx.device = Mock(spec=DeviceContext)
29
+ ctx.device.mobile_platform = DevicePlatform.ANDROID
30
+ ctx.device.device_id = "test_device_123"
31
+ ctx.device.device_width = 1080
32
+ ctx.device.device_height = 2340
33
+ ctx.device.host_platform = "LINUX"
34
+
35
+ ctx.ui_adb_client = Mock()
36
+
37
+ # Mock the ADB client for Android
38
+ ctx.adb_client = Mock()
39
+ mock_device = Mock()
40
+ mock_device.shell = Mock(return_value="")
41
+ ctx.adb_client.device = Mock(return_value=mock_device)
42
+
43
+ # Mock the ADB client for Android
44
+ mock_response = Mock()
45
+ mock_response.json.return_value = {"elements": []}
46
+ ctx.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
47
+
48
+ return ctx
49
+
50
+
51
+ @pytest.fixture
52
+ def mock_state():
53
+ """Create a mock State for testing."""
54
+ state = Mock()
55
+ state.latest_ui_hierarchy = []
56
+ return state
57
+
58
+
59
+ @pytest.fixture
60
+ def sample_element():
61
+ """Create a sample UI element for testing."""
62
+ return {
63
+ "resourceId": "com.example:id/text_input",
64
+ "text": "Sample text",
65
+ "bounds": {"x": 100, "y": 200, "width": 300, "height": 50},
66
+ "focused": "false",
67
+ }
68
+
69
+
70
+ @pytest.fixture
71
+ def sample_rich_element():
72
+ """Create a sample rich UI element for testing."""
73
+ return {
74
+ "attributes": {
75
+ "resource-id": "com.example:id/text_input",
76
+ "focused": "false",
77
+ "text": "Sample text",
78
+ "bounds": {"x": 100, "y": 200, "width": 300, "height": 50},
79
+ },
80
+ "children": [],
81
+ }
82
+
83
+
84
+ class TestMoveCursorToEndIfBounds:
85
+ """Test cases for move_cursor_to_end_if_bounds function."""
86
+
87
+ @patch("minitap.mobile_use.tools.utils.tap")
88
+ @patch("minitap.mobile_use.tools.utils.find_element_by_resource_id")
89
+ def test_move_cursor_with_resource_id(
90
+ self, mock_find_element, mock_tap, mock_context, mock_state, sample_element
91
+ ):
92
+ """Test moving cursor using resource_id (highest priority)."""
93
+ mock_state.latest_ui_hierarchy = [sample_element]
94
+ mock_find_element.return_value = sample_element
95
+
96
+ target = Target(
97
+ resource_id="com.example:id/text_input",
98
+ resource_id_index=None,
99
+ text=None,
100
+ text_index=None,
101
+ bounds=None,
102
+ )
103
+ result = asyncio.run(
104
+ move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
105
+ )
106
+
107
+ mock_find_element.assert_called_once_with(
108
+ ui_hierarchy=[sample_element],
109
+ resource_id="com.example:id/text_input",
110
+ index=0,
111
+ )
112
+ mock_tap.assert_called_once()
113
+ call_args = mock_tap.call_args[1]
114
+ selector_request = call_args["selector_request"]
115
+ assert isinstance(selector_request, SelectorRequestWithCoordinates)
116
+ coords = selector_request.coordinates
117
+ assert coords.x == 397 # 100 + 300 * 0.99
118
+ assert coords.y == 249 # 200 + 50 * 0.99
119
+ assert result == sample_element
120
+
121
+ @patch("minitap.mobile_use.tools.utils.tap")
122
+ @patch("minitap.mobile_use.tools.utils.find_element_by_resource_id")
123
+ def test_move_cursor_with_coordinates_only(
124
+ self, mock_find_element, mock_tap, mock_context, mock_state
125
+ ):
126
+ """Test moving cursor when only coordinates are provided."""
127
+ bounds = ElementBounds(x=50, y=150, width=200, height=40)
128
+ target = Target(
129
+ resource_id=None,
130
+ resource_id_index=None,
131
+ text=None,
132
+ text_index=None,
133
+ bounds=bounds,
134
+ )
135
+
136
+ result = asyncio.run(
137
+ move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
138
+ )
139
+
140
+ mock_find_element.assert_not_called()
141
+ mock_tap.assert_called_once()
142
+ call_args = mock_tap.call_args[1]
143
+ selector_request = call_args["selector_request"]
144
+ coords = selector_request.coordinates
145
+ assert coords.x == 248 # 50 + 200 * 0.99
146
+ assert coords.y == 189 # 150 + 40 * 0.99
147
+ assert result is None # No element is returned when using coords directly
148
+
149
+ @patch("minitap.mobile_use.tools.utils.tap")
150
+ @patch("minitap.mobile_use.tools.utils.find_element_by_text")
151
+ def test_move_cursor_with_text_only_success(
152
+ self, mock_find_text, mock_tap, mock_context, mock_state, sample_element
153
+ ):
154
+ """Test moving cursor when only text is provided and succeeds."""
155
+ mock_state.latest_ui_hierarchy = [sample_element]
156
+ mock_find_text.return_value = sample_element
157
+
158
+ target = Target(
159
+ resource_id=None,
160
+ resource_id_index=None,
161
+ text="Sample text",
162
+ text_index=0,
163
+ bounds=None,
164
+ )
165
+ result = asyncio.run(
166
+ move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
167
+ )
168
+
169
+ mock_find_text.assert_called_once_with([sample_element], "Sample text", index=0)
170
+ mock_tap.assert_called_once()
171
+ assert result == sample_element
172
+
173
+ @patch("minitap.mobile_use.tools.utils.tap")
174
+ @patch("minitap.mobile_use.tools.utils.find_element_by_text")
175
+ def test_move_cursor_with_text_only_element_not_found(
176
+ self, mock_find_text, mock_tap, mock_context, mock_state
177
+ ):
178
+ """Test when searching by text finds no element."""
179
+ mock_state.latest_ui_hierarchy = []
180
+ mock_find_text.return_value = None
181
+
182
+ target = Target(
183
+ resource_id=None,
184
+ resource_id_index=None,
185
+ text="Nonexistent text",
186
+ text_index=None,
187
+ bounds=None,
188
+ )
189
+ result = asyncio.run(
190
+ move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
191
+ )
192
+
193
+ mock_tap.assert_not_called()
194
+ assert result is None
195
+
196
+ @patch("minitap.mobile_use.tools.utils.tap")
197
+ @patch("minitap.mobile_use.tools.utils.find_element_by_text")
198
+ def test_move_cursor_with_text_only_no_bounds(
199
+ self, mock_find_text, mock_tap, mock_context, mock_state
200
+ ):
201
+ """Test when element is found by text but has no bounds."""
202
+ element_no_bounds = {"text": "Text without bounds"}
203
+ mock_state.latest_ui_hierarchy = [element_no_bounds]
204
+ mock_find_text.return_value = element_no_bounds
205
+
206
+ target = Target(
207
+ resource_id=None,
208
+ resource_id_index=None,
209
+ text="Text without bounds",
210
+ text_index=None,
211
+ bounds=None,
212
+ )
213
+ result = asyncio.run(
214
+ move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
215
+ )
216
+
217
+ mock_tap.assert_not_called()
218
+ assert result is None # Should return None as no action was taken
219
+
220
+ @patch("minitap.mobile_use.tools.utils.find_element_by_resource_id")
221
+ def test_move_cursor_element_not_found_by_id(self, mock_find_element, mock_context, mock_state):
222
+ """Test when element is not found by resource_id."""
223
+ mock_find_element.return_value = None
224
+
225
+ target = Target(
226
+ resource_id="com.example:id/nonexistent",
227
+ resource_id_index=None,
228
+ text=None,
229
+ text_index=None,
230
+ bounds=None,
231
+ )
232
+ result = asyncio.run(
233
+ move_cursor_to_end_if_bounds(ctx=mock_context, state=mock_state, target=target)
234
+ )
235
+
236
+ assert result is None
237
+
238
+
239
+ class TestFocusElementIfNeeded:
240
+ """Test cases for focus_element_if_needed function."""
241
+
242
+ @patch("minitap.mobile_use.tools.utils.tap")
243
+ @patch("minitap.mobile_use.tools.utils.find_element_by_resource_id")
244
+ def test_focus_element_already_focused(
245
+ self, mock_find_element, mock_tap, mock_context, sample_rich_element
246
+ ):
247
+ """Test when element is already focused."""
248
+ focused_element = sample_rich_element.copy()
249
+ focused_element["attributes"]["focused"] = "true"
250
+
251
+ mock_response = Mock()
252
+ mock_response.json.return_value = {"elements": [focused_element]}
253
+ mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
254
+ mock_find_element.return_value = focused_element["attributes"]
255
+
256
+ target = Target(
257
+ resource_id="com.example:id/text_input",
258
+ resource_id_index=None,
259
+ text=None,
260
+ text_index=None,
261
+ bounds=None,
262
+ )
263
+ result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
264
+
265
+ mock_tap.assert_not_called()
266
+ assert result == "resource_id"
267
+ mock_context.ui_adb_client.get_screen_data.assert_called_once()
268
+
269
+ @patch("minitap.mobile_use.tools.utils.tap")
270
+ @patch("minitap.mobile_use.tools.utils.find_element_by_resource_id")
271
+ def test_focus_element_needs_focus_success(
272
+ self, mock_find_element, mock_tap, mock_context, sample_rich_element
273
+ ):
274
+ """Test when element needs focus and focusing succeeds."""
275
+ unfocused_element = sample_rich_element
276
+ focused_element = {
277
+ "attributes": {
278
+ "resource-id": "com.example:id/text_input",
279
+ "focused": "true",
280
+ },
281
+ "children": [],
282
+ }
283
+
284
+ mock_find_element.side_effect = [
285
+ unfocused_element["attributes"],
286
+ focused_element["attributes"],
287
+ ]
288
+
289
+ target = Target(
290
+ resource_id="com.example:id/text_input",
291
+ resource_id_index=None,
292
+ text=None,
293
+ text_index=None,
294
+ bounds=None,
295
+ )
296
+ result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
297
+
298
+ mock_tap.assert_called_once_with(
299
+ ctx=mock_context,
300
+ selector_request=IdSelectorRequest(id="com.example:id/text_input"),
301
+ index=0,
302
+ )
303
+ assert mock_context.ui_adb_client.get_screen_data.call_count == 2
304
+ assert result == "resource_id"
305
+
306
+ @patch("minitap.mobile_use.tools.utils.tap")
307
+ @patch("minitap.mobile_use.tools.utils.logger")
308
+ @patch("minitap.mobile_use.tools.utils.find_element_by_resource_id")
309
+ def test_focus_id_and_text_mismatch_fallback_to_text(
310
+ self, mock_find_id, mock_logger, mock_tap, mock_context, sample_rich_element
311
+ ):
312
+ """Test fallback when resource_id and text point to different elements."""
313
+ element_from_id = sample_rich_element["attributes"].copy()
314
+ element_from_id["text"] = "Different text"
315
+
316
+ element_from_text = sample_rich_element.copy()
317
+ element_from_text["attributes"]["bounds"] = {
318
+ "x": 10,
319
+ "y": 20,
320
+ "width": 100,
321
+ "height": 30,
322
+ }
323
+
324
+ mock_response = Mock()
325
+ mock_response.json.return_value = {"elements": [element_from_text]}
326
+ mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
327
+ mock_find_id.return_value = element_from_id
328
+
329
+ with patch("minitap.mobile_use.tools.utils.find_element_by_text") as mock_find_text:
330
+ mock_find_text.return_value = element_from_text["attributes"]
331
+
332
+ target = Target(
333
+ resource_id="com.example:id/text_input",
334
+ resource_id_index=None,
335
+ text="Sample text",
336
+ text_index=None,
337
+ bounds=None,
338
+ )
339
+ result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
340
+
341
+ mock_logger.warning.assert_called_once()
342
+ mock_tap.assert_called_once()
343
+ assert result == "text"
344
+
345
+ @patch("minitap.mobile_use.tools.utils.tap")
346
+ @patch("minitap.mobile_use.tools.utils.find_element_by_text")
347
+ def test_focus_fallback_to_text(
348
+ self, mock_find_text, mock_tap, mock_context, sample_rich_element
349
+ ):
350
+ """Test fallback to focusing using text."""
351
+ element_with_bounds = sample_rich_element.copy()
352
+ element_with_bounds["attributes"]["bounds"] = {
353
+ "x": 10,
354
+ "y": 20,
355
+ "width": 100,
356
+ "height": 30,
357
+ }
358
+
359
+ mock_response = Mock()
360
+ mock_response.json.return_value = {"elements": [element_with_bounds]}
361
+ mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
362
+ mock_find_text.return_value = element_with_bounds["attributes"]
363
+
364
+ target = Target(
365
+ resource_id=None,
366
+ resource_id_index=None,
367
+ text="Sample text",
368
+ text_index=None,
369
+ bounds=None,
370
+ )
371
+ result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
372
+
373
+ mock_find_text.assert_called_once()
374
+ mock_tap.assert_called_once()
375
+ call_args = mock_tap.call_args[1]
376
+ selector = call_args["selector_request"]
377
+ assert isinstance(selector, SelectorRequestWithCoordinates)
378
+ assert selector.coordinates.x == 60 # 10 + 100/2
379
+ assert selector.coordinates.y == 35 # 20 + 30/2
380
+ assert result == "text"
381
+
382
+ @patch("minitap.mobile_use.tools.utils.logger")
383
+ def test_focus_all_locators_fail(self, mock_logger, mock_context):
384
+ """Test failure when no locator can find an element."""
385
+
386
+ mock_response = Mock()
387
+ mock_response.json.return_value = {"elements": []}
388
+ mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
389
+ with (
390
+ patch("minitap.mobile_use.tools.utils.find_element_by_resource_id") as mock_find_id,
391
+ patch("minitap.mobile_use.tools.utils.find_element_by_text") as mock_find_text,
392
+ ):
393
+ mock_find_id.return_value = None
394
+ mock_find_text.return_value = None
395
+
396
+ target = Target(
397
+ resource_id="nonexistent",
398
+ resource_id_index=None,
399
+ text="nonexistent",
400
+ text_index=None,
401
+ bounds=None,
402
+ )
403
+ result = asyncio.run(focus_element_if_needed(ctx=mock_context, target=target))
404
+
405
+ mock_logger.error.assert_called_once_with(
406
+ "Failed to focus element."
407
+ + " No valid locator (resource_id, coordinates, or text) succeeded."
408
+ )
409
+ assert result is None
410
+
411
+
412
+ if __name__ == "__main__":
413
+ pytest.main([__file__])
@@ -0,0 +1,16 @@
1
+ from collections.abc import Callable
2
+
3
+ from langchain_core.tools import BaseTool
4
+ from pydantic import BaseModel
5
+
6
+ from minitap.mobile_use.context import MobileUseContext
7
+
8
+
9
+ class ToolWrapper(BaseModel):
10
+ tool_fn_getter: Callable[[MobileUseContext], BaseTool]
11
+ on_success_fn: Callable[..., str]
12
+ on_failure_fn: Callable[..., str]
13
+
14
+
15
+ class CompositeToolWrapper(ToolWrapper):
16
+ composite_tools_fn_getter: Callable[[MobileUseContext], list[BaseTool]]
@@ -0,0 +1,35 @@
1
+ from pydantic import BaseModel, Field, model_validator
2
+
3
+ from minitap.mobile_use.utils.ui_hierarchy import ElementBounds
4
+
5
+
6
+ class Target(BaseModel):
7
+ """
8
+ A comprehensive locator for a UI element, supporting a fallback mechanism.
9
+ """
10
+
11
+ resource_id: str | None = Field(None, description="The resource-id of the element.")
12
+ resource_id_index: int | None = Field(
13
+ None,
14
+ description="The zero-based index if multiple elements share the same resource-id.",
15
+ )
16
+ text: str | None = Field(
17
+ None, description="The text content of the element (e.g., a label or placeholder)."
18
+ )
19
+ text_index: int | None = Field(
20
+ None, description="The zero-based index if multiple elements share the same text."
21
+ )
22
+ bounds: ElementBounds | None = Field(
23
+ None, description="The x, y, width, and height of the element."
24
+ )
25
+
26
+ @model_validator(mode="after")
27
+ def _default_indices(self):
28
+ # Treat empty strings like “not provided”
29
+ if (
30
+ self.resource_id is not None and self.resource_id != ""
31
+ ) and self.resource_id_index is None:
32
+ self.resource_id_index = 0
33
+ if (self.text is not None and self.text != "") and self.text_index is None:
34
+ self.text_index = 0
35
+ return self