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.
- minitap/mobile_use/__init__.py +0 -0
- minitap/mobile_use/agents/contextor/contextor.md +55 -0
- minitap/mobile_use/agents/contextor/contextor.py +175 -0
- minitap/mobile_use/agents/contextor/types.py +36 -0
- minitap/mobile_use/agents/cortex/cortex.md +135 -0
- minitap/mobile_use/agents/cortex/cortex.py +152 -0
- minitap/mobile_use/agents/cortex/types.py +15 -0
- minitap/mobile_use/agents/executor/executor.md +42 -0
- minitap/mobile_use/agents/executor/executor.py +87 -0
- minitap/mobile_use/agents/executor/tool_node.py +152 -0
- minitap/mobile_use/agents/hopper/hopper.md +15 -0
- minitap/mobile_use/agents/hopper/hopper.py +44 -0
- minitap/mobile_use/agents/orchestrator/human.md +12 -0
- minitap/mobile_use/agents/orchestrator/orchestrator.md +21 -0
- minitap/mobile_use/agents/orchestrator/orchestrator.py +134 -0
- minitap/mobile_use/agents/orchestrator/types.py +11 -0
- minitap/mobile_use/agents/outputter/human.md +25 -0
- minitap/mobile_use/agents/outputter/outputter.py +85 -0
- minitap/mobile_use/agents/outputter/test_outputter.py +167 -0
- minitap/mobile_use/agents/planner/human.md +14 -0
- minitap/mobile_use/agents/planner/planner.md +126 -0
- minitap/mobile_use/agents/planner/planner.py +101 -0
- minitap/mobile_use/agents/planner/types.py +51 -0
- minitap/mobile_use/agents/planner/utils.py +70 -0
- minitap/mobile_use/agents/summarizer/summarizer.py +35 -0
- minitap/mobile_use/agents/video_analyzer/__init__.py +5 -0
- minitap/mobile_use/agents/video_analyzer/human.md +5 -0
- minitap/mobile_use/agents/video_analyzer/video_analyzer.md +37 -0
- minitap/mobile_use/agents/video_analyzer/video_analyzer.py +111 -0
- minitap/mobile_use/clients/browserstack_client.py +477 -0
- minitap/mobile_use/clients/idb_client.py +429 -0
- minitap/mobile_use/clients/ios_client.py +332 -0
- minitap/mobile_use/clients/ios_client_config.py +141 -0
- minitap/mobile_use/clients/ui_automator_client.py +330 -0
- minitap/mobile_use/clients/wda_client.py +526 -0
- minitap/mobile_use/clients/wda_lifecycle.py +367 -0
- minitap/mobile_use/config.py +413 -0
- minitap/mobile_use/constants.py +3 -0
- minitap/mobile_use/context.py +106 -0
- minitap/mobile_use/controllers/__init__.py +0 -0
- minitap/mobile_use/controllers/android_controller.py +524 -0
- minitap/mobile_use/controllers/controller_factory.py +46 -0
- minitap/mobile_use/controllers/device_controller.py +182 -0
- minitap/mobile_use/controllers/ios_controller.py +436 -0
- minitap/mobile_use/controllers/platform_specific_commands_controller.py +199 -0
- minitap/mobile_use/controllers/types.py +106 -0
- minitap/mobile_use/controllers/unified_controller.py +193 -0
- minitap/mobile_use/graph/graph.py +160 -0
- minitap/mobile_use/graph/state.py +115 -0
- minitap/mobile_use/main.py +309 -0
- minitap/mobile_use/sdk/__init__.py +12 -0
- minitap/mobile_use/sdk/agent.py +1294 -0
- minitap/mobile_use/sdk/builders/__init__.py +10 -0
- minitap/mobile_use/sdk/builders/agent_config_builder.py +307 -0
- minitap/mobile_use/sdk/builders/index.py +15 -0
- minitap/mobile_use/sdk/builders/task_request_builder.py +236 -0
- minitap/mobile_use/sdk/constants.py +1 -0
- minitap/mobile_use/sdk/examples/README.md +83 -0
- minitap/mobile_use/sdk/examples/__init__.py +1 -0
- minitap/mobile_use/sdk/examples/app_lock_messaging.py +54 -0
- minitap/mobile_use/sdk/examples/platform_manual_task_example.py +67 -0
- minitap/mobile_use/sdk/examples/platform_minimal_example.py +48 -0
- minitap/mobile_use/sdk/examples/simple_photo_organizer.py +76 -0
- minitap/mobile_use/sdk/examples/smart_notification_assistant.py +225 -0
- minitap/mobile_use/sdk/examples/video_transcription_example.py +117 -0
- minitap/mobile_use/sdk/services/cloud_mobile.py +656 -0
- minitap/mobile_use/sdk/services/platform.py +434 -0
- minitap/mobile_use/sdk/types/__init__.py +51 -0
- minitap/mobile_use/sdk/types/agent.py +84 -0
- minitap/mobile_use/sdk/types/exceptions.py +138 -0
- minitap/mobile_use/sdk/types/platform.py +183 -0
- minitap/mobile_use/sdk/types/task.py +269 -0
- minitap/mobile_use/sdk/utils.py +29 -0
- minitap/mobile_use/services/accessibility.py +100 -0
- minitap/mobile_use/services/llm.py +247 -0
- minitap/mobile_use/services/telemetry.py +421 -0
- minitap/mobile_use/tools/index.py +67 -0
- minitap/mobile_use/tools/mobile/back.py +52 -0
- minitap/mobile_use/tools/mobile/erase_one_char.py +56 -0
- minitap/mobile_use/tools/mobile/focus_and_clear_text.py +317 -0
- minitap/mobile_use/tools/mobile/focus_and_input_text.py +153 -0
- minitap/mobile_use/tools/mobile/launch_app.py +86 -0
- minitap/mobile_use/tools/mobile/long_press_on.py +169 -0
- minitap/mobile_use/tools/mobile/open_link.py +62 -0
- minitap/mobile_use/tools/mobile/press_key.py +83 -0
- minitap/mobile_use/tools/mobile/stop_app.py +62 -0
- minitap/mobile_use/tools/mobile/swipe.py +156 -0
- minitap/mobile_use/tools/mobile/tap.py +154 -0
- minitap/mobile_use/tools/mobile/video_recording.py +177 -0
- minitap/mobile_use/tools/mobile/wait_for_delay.py +81 -0
- minitap/mobile_use/tools/scratchpad.py +147 -0
- minitap/mobile_use/tools/test_utils.py +413 -0
- minitap/mobile_use/tools/tool_wrapper.py +16 -0
- minitap/mobile_use/tools/types.py +35 -0
- minitap/mobile_use/tools/utils.py +336 -0
- minitap/mobile_use/utils/app_launch_utils.py +173 -0
- minitap/mobile_use/utils/cli_helpers.py +37 -0
- minitap/mobile_use/utils/cli_selection.py +143 -0
- minitap/mobile_use/utils/conversations.py +31 -0
- minitap/mobile_use/utils/decorators.py +124 -0
- minitap/mobile_use/utils/errors.py +6 -0
- minitap/mobile_use/utils/file.py +13 -0
- minitap/mobile_use/utils/logger.py +183 -0
- minitap/mobile_use/utils/media.py +186 -0
- minitap/mobile_use/utils/recorder.py +52 -0
- minitap/mobile_use/utils/requests_utils.py +37 -0
- minitap/mobile_use/utils/shell_utils.py +20 -0
- minitap/mobile_use/utils/test_ui_hierarchy.py +178 -0
- minitap/mobile_use/utils/time.py +6 -0
- minitap/mobile_use/utils/ui_hierarchy.py +132 -0
- minitap/mobile_use/utils/video.py +281 -0
- minitap_mobile_use-3.3.0.dist-info/METADATA +329 -0
- minitap_mobile_use-3.3.0.dist-info/RECORD +115 -0
- minitap_mobile_use-3.3.0.dist-info/WHEEL +4 -0
- 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
|