mobile-mcp-ai 2.1.2__py3-none-any.whl → 2.5.8__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.
- mobile_mcp/__init__.py +34 -0
- mobile_mcp/config.py +142 -0
- mobile_mcp/core/basic_tools_lite.py +3266 -0
- {core → mobile_mcp/core}/device_manager.py +2 -2
- mobile_mcp/core/dynamic_config.py +272 -0
- mobile_mcp/core/ios_client_wda.py +569 -0
- mobile_mcp/core/ios_device_manager_wda.py +306 -0
- {core → mobile_mcp/core}/mobile_client.py +279 -39
- mobile_mcp/core/template_matcher.py +429 -0
- mobile_mcp/core/templates/close_buttons/auto_x_0112_151217.png +0 -0
- mobile_mcp/core/templates/close_buttons/auto_x_0112_152037.png +0 -0
- mobile_mcp/core/templates/close_buttons/auto_x_0112_152840.png +0 -0
- mobile_mcp/core/templates/close_buttons/auto_x_0112_153256.png +0 -0
- mobile_mcp/core/templates/close_buttons/auto_x_0112_154847.png +0 -0
- mobile_mcp/core/templates/close_buttons/gray_x_stock_ad.png +0 -0
- {core → mobile_mcp/core}/utils/smart_wait.py +3 -3
- mobile_mcp/mcp_tools/__init__.py +10 -0
- mobile_mcp/mcp_tools/mcp_server.py +1071 -0
- mobile_mcp_ai-2.5.8.dist-info/METADATA +469 -0
- mobile_mcp_ai-2.5.8.dist-info/RECORD +32 -0
- mobile_mcp_ai-2.5.8.dist-info/entry_points.txt +2 -0
- mobile_mcp_ai-2.5.8.dist-info/licenses/LICENSE +201 -0
- mobile_mcp_ai-2.5.8.dist-info/top_level.txt +1 -0
- core/ai/__init__.py +0 -11
- core/ai/ai_analyzer.py +0 -197
- core/ai/ai_config.py +0 -116
- core/ai/ai_platform_adapter.py +0 -399
- core/ai/smart_test_executor.py +0 -520
- core/ai/test_generator.py +0 -365
- core/ai/test_generator_from_history.py +0 -391
- core/ai/test_generator_standalone.py +0 -293
- core/assertion/__init__.py +0 -9
- core/assertion/smart_assertion.py +0 -341
- core/basic_tools.py +0 -377
- core/h5/__init__.py +0 -10
- core/h5/h5_handler.py +0 -548
- core/ios_client.py +0 -219
- core/ios_device_manager.py +0 -252
- core/locator/__init__.py +0 -10
- core/locator/cursor_ai_auto_analyzer.py +0 -119
- core/locator/cursor_vision_helper.py +0 -414
- core/locator/mobile_smart_locator.py +0 -1640
- core/locator/position_analyzer.py +0 -813
- core/locator/script_updater.py +0 -157
- core/nl_test_runner.py +0 -585
- core/smart_app_launcher.py +0 -334
- core/smart_tools.py +0 -311
- mcp/__init__.py +0 -8
- mcp/mcp_server.py +0 -1919
- mcp/mcp_server_simple.py +0 -476
- mobile_mcp_ai-2.1.2.dist-info/METADATA +0 -567
- mobile_mcp_ai-2.1.2.dist-info/RECORD +0 -45
- mobile_mcp_ai-2.1.2.dist-info/entry_points.txt +0 -2
- mobile_mcp_ai-2.1.2.dist-info/top_level.txt +0 -4
- vision/__init__.py +0 -10
- vision/vision_locator.py +0 -404
- {core → mobile_mcp/core}/__init__.py +0 -0
- {core → mobile_mcp/core}/utils/__init__.py +0 -0
- {core → mobile_mcp/core}/utils/logger.py +0 -0
- {core → mobile_mcp/core}/utils/operation_history_manager.py +0 -0
- {utils → mobile_mcp/utils}/__init__.py +0 -0
- {utils → mobile_mcp/utils}/logger.py +0 -0
- {utils → mobile_mcp/utils}/xml_formatter.py +0 -0
- {utils → mobile_mcp/utils}/xml_parser.py +0 -0
- {mobile_mcp_ai-2.1.2.dist-info → mobile_mcp_ai-2.5.8.dist-info}/WHEEL +0 -0
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
"""
|
|
4
|
-
基于操作历史生成测试脚本 - 智能定位 + 自动降级
|
|
5
|
-
|
|
6
|
-
功能:
|
|
7
|
-
1. 从操作历史(operation_history)生成脚本
|
|
8
|
-
2. 优先使用MCP验证过的定位方式(快速、准确)
|
|
9
|
-
3. 定位失败时自动降级到智能定位(自愈能力)
|
|
10
|
-
4. 页面改版后大部分用例能自动适应
|
|
11
|
-
|
|
12
|
-
用法:
|
|
13
|
-
generator = TestGeneratorFromHistory()
|
|
14
|
-
script = generator.generate_from_history(
|
|
15
|
-
test_name="测试用例",
|
|
16
|
-
package_name="com.im30.way",
|
|
17
|
-
operation_history=client.operation_history
|
|
18
|
-
)
|
|
19
|
-
generator.save("test_generated.py", script)
|
|
20
|
-
"""
|
|
21
|
-
import sys
|
|
22
|
-
import re
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
from typing import List, Dict
|
|
25
|
-
from datetime import datetime
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class TestGeneratorFromHistory:
|
|
29
|
-
"""
|
|
30
|
-
基于操作历史生成测试脚本
|
|
31
|
-
|
|
32
|
-
特点:
|
|
33
|
-
- 优先使用MCP验证过的定位方式(性能最优)
|
|
34
|
-
- 定位失败时自动降级到智能定位(自愈能力)
|
|
35
|
-
- 页面改版后大部分用例能自动适应
|
|
36
|
-
"""
|
|
37
|
-
|
|
38
|
-
def __init__(self, output_dir: str = "tests"):
|
|
39
|
-
"""
|
|
40
|
-
初始化生成器
|
|
41
|
-
|
|
42
|
-
Args:
|
|
43
|
-
output_dir: 生成的测试文件输出目录(默认tests,用于pytest)
|
|
44
|
-
"""
|
|
45
|
-
self.output_dir = Path(output_dir)
|
|
46
|
-
self.output_dir.mkdir(exist_ok=True)
|
|
47
|
-
|
|
48
|
-
# 🎯 弹窗关键词(用于识别可选操作)
|
|
49
|
-
self.popup_keywords = [
|
|
50
|
-
"允许", "取消", "确定", "同意", "拒绝", "关闭", "跳过",
|
|
51
|
-
"知道了", "我知道了", "好的", "稍后", "暂不", "以后再说",
|
|
52
|
-
"Allow", "Cancel", "OK", "Agree", "Deny", "Close", "Skip",
|
|
53
|
-
"Got it", "Later", "Not now"
|
|
54
|
-
]
|
|
55
|
-
|
|
56
|
-
# 🎯 弹窗resource-id特征
|
|
57
|
-
self.popup_id_patterns = ["permission", "dialog", "alert", "popup", "grant"]
|
|
58
|
-
|
|
59
|
-
def _is_popup_element(self, element: str, ref: str) -> bool:
|
|
60
|
-
"""
|
|
61
|
-
判断是否是弹窗元素(可选操作)
|
|
62
|
-
|
|
63
|
-
Args:
|
|
64
|
-
element: 元素描述
|
|
65
|
-
ref: 元素定位方式
|
|
66
|
-
|
|
67
|
-
Returns:
|
|
68
|
-
True表示是弹窗元素
|
|
69
|
-
"""
|
|
70
|
-
# 检查元素描述是否包含弹窗关键词
|
|
71
|
-
for keyword in self.popup_keywords:
|
|
72
|
-
if keyword in element:
|
|
73
|
-
return True
|
|
74
|
-
|
|
75
|
-
# 检查resource-id是否包含弹窗特征
|
|
76
|
-
ref_lower = ref.lower()
|
|
77
|
-
for pattern in self.popup_id_patterns:
|
|
78
|
-
if pattern in ref_lower:
|
|
79
|
-
return True
|
|
80
|
-
|
|
81
|
-
return False
|
|
82
|
-
|
|
83
|
-
def _is_dropdown_scenario(self, operations: List[Dict], index: int) -> bool:
|
|
84
|
-
"""
|
|
85
|
-
判断是否是下拉框场景
|
|
86
|
-
|
|
87
|
-
Args:
|
|
88
|
-
operations: 操作历史列表
|
|
89
|
-
index: 当前操作的索引
|
|
90
|
-
|
|
91
|
-
Returns:
|
|
92
|
-
True表示当前操作是下拉框选择的第二步(需要等待)
|
|
93
|
-
"""
|
|
94
|
-
# 检查:当前是click,且前一个也是click
|
|
95
|
-
if index > 0:
|
|
96
|
-
current = operations[index]
|
|
97
|
-
previous = operations[index - 1]
|
|
98
|
-
|
|
99
|
-
if current.get('action') == 'click' and previous.get('action') == 'click':
|
|
100
|
-
current_element = current.get('element', '')
|
|
101
|
-
|
|
102
|
-
# 🎯 排除明显的按钮关键词
|
|
103
|
-
button_keywords = ["按钮", "button", "btn", "继续", "下一步", "跳过", "完成"]
|
|
104
|
-
for keyword in button_keywords:
|
|
105
|
-
if keyword in current_element.lower():
|
|
106
|
-
return False
|
|
107
|
-
|
|
108
|
-
# 🎯 选项通常是1-5个字符(排除按钮后)
|
|
109
|
-
# 例如:"北京"(2)、"男"(1)、"确定"(2)、"China"(5)
|
|
110
|
-
if 1 <= len(current_element) <= 5:
|
|
111
|
-
return True
|
|
112
|
-
|
|
113
|
-
return False
|
|
114
|
-
|
|
115
|
-
def generate_from_history(
|
|
116
|
-
self,
|
|
117
|
-
test_name: str,
|
|
118
|
-
package_name: str,
|
|
119
|
-
operation_history: List[Dict]
|
|
120
|
-
) -> str:
|
|
121
|
-
"""
|
|
122
|
-
从操作历史生成测试脚本
|
|
123
|
-
|
|
124
|
-
Args:
|
|
125
|
-
test_name: 测试用例名称
|
|
126
|
-
package_name: App包名
|
|
127
|
-
operation_history: 操作历史列表
|
|
128
|
-
|
|
129
|
-
Returns:
|
|
130
|
-
生成的测试脚本内容
|
|
131
|
-
"""
|
|
132
|
-
# 生成文件名(中文转拼音或直接使用)
|
|
133
|
-
safe_name = re.sub(r'[^\w\s-]', '', test_name).strip().replace(' ', '_')
|
|
134
|
-
|
|
135
|
-
# 生成脚本内容(pytest格式)
|
|
136
|
-
script_lines = [
|
|
137
|
-
"#!/usr/bin/env python3",
|
|
138
|
-
"# -*- coding: utf-8 -*-",
|
|
139
|
-
f'"""',
|
|
140
|
-
f"移动端测试用例: {test_name}",
|
|
141
|
-
f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
142
|
-
f"",
|
|
143
|
-
f"✨ 特性:智能定位 + 自动降级",
|
|
144
|
-
f" - 优先使用MCP验证过的定位方式(快速)",
|
|
145
|
-
f" - 定位失败时自动降级到智能定位(自愈)",
|
|
146
|
-
f" - 页面改版后大部分用例能自动适应",
|
|
147
|
-
f"",
|
|
148
|
-
f"运行方式:",
|
|
149
|
-
f" pytest {safe_name}.py -v",
|
|
150
|
-
f" pytest {safe_name}.py --alluredir=./allure-results # 生成allure报告",
|
|
151
|
-
f'"""',
|
|
152
|
-
"import asyncio",
|
|
153
|
-
"import pytest",
|
|
154
|
-
"import sys",
|
|
155
|
-
"from pathlib import Path",
|
|
156
|
-
"",
|
|
157
|
-
"# 添加backend目录到路径",
|
|
158
|
-
"# tests目录结构: backend/mobile_mcp/tests/test_xxx.py",
|
|
159
|
-
"# 需要导入: backend/mobile_mcp/core/mobile_client.py",
|
|
160
|
-
"sys.path.insert(0, str(Path(__file__).parent.parent))",
|
|
161
|
-
"",
|
|
162
|
-
"from mobile_mcp.core.mobile_client import MobileClient",
|
|
163
|
-
"from mobile_mcp.core.locator.mobile_smart_locator import MobileSmartLocator",
|
|
164
|
-
"",
|
|
165
|
-
"",
|
|
166
|
-
f"PACKAGE_NAME = \"{package_name}\"",
|
|
167
|
-
"",
|
|
168
|
-
"",
|
|
169
|
-
"@pytest.fixture(scope='function')",
|
|
170
|
-
"async def mobile_client():",
|
|
171
|
-
" \"\"\"",
|
|
172
|
-
" pytest fixture: 创建并返回MobileClient实例",
|
|
173
|
-
" scope='function': 每个测试函数都会创建一个新的client",
|
|
174
|
-
" \"\"\"",
|
|
175
|
-
" client = MobileClient(device_id=None)",
|
|
176
|
-
" ",
|
|
177
|
-
" # 🎯 附加智能定位器(用于降级场景)",
|
|
178
|
-
" client.smart_locator = MobileSmartLocator(client)",
|
|
179
|
-
" ",
|
|
180
|
-
" # 启动App",
|
|
181
|
-
" print(f\"\\n📱 启动App: {{PACKAGE_NAME}}\", file=sys.stderr)",
|
|
182
|
-
" result = await client.launch_app(PACKAGE_NAME, wait_time=5)",
|
|
183
|
-
" if not result.get('success'):",
|
|
184
|
-
" raise Exception(f\"启动App失败: {{result.get('reason')}}\")",
|
|
185
|
-
" ",
|
|
186
|
-
" await asyncio.sleep(2) # 等待页面加载",
|
|
187
|
-
" ",
|
|
188
|
-
" yield client",
|
|
189
|
-
" ",
|
|
190
|
-
" # 清理",
|
|
191
|
-
" client.device_manager.disconnect()",
|
|
192
|
-
"",
|
|
193
|
-
"",
|
|
194
|
-
f"@pytest.mark.asyncio",
|
|
195
|
-
f"async def test_{safe_name.lower()}(mobile_client):",
|
|
196
|
-
f' """',
|
|
197
|
-
f" 测试用例: {test_name}",
|
|
198
|
-
f" ",
|
|
199
|
-
f" Args:",
|
|
200
|
-
f" mobile_client: pytest fixture,已启动App的MobileClient实例",
|
|
201
|
-
f' """',
|
|
202
|
-
f" client = mobile_client",
|
|
203
|
-
f" ",
|
|
204
|
-
f" print(\"=\" * 60, file=sys.stderr)",
|
|
205
|
-
f" print(f\"🚀 {test_name}\", file=sys.stderr)",
|
|
206
|
-
f" print(\"=\" * 60, file=sys.stderr)",
|
|
207
|
-
f" ",
|
|
208
|
-
f" try:",
|
|
209
|
-
]
|
|
210
|
-
|
|
211
|
-
# 根据操作历史生成测试步骤
|
|
212
|
-
step_index = 1
|
|
213
|
-
for op_index, operation in enumerate(operation_history):
|
|
214
|
-
action = operation.get('action')
|
|
215
|
-
element = operation.get('element', '')
|
|
216
|
-
ref = operation.get('ref', '')
|
|
217
|
-
|
|
218
|
-
if action == 'click':
|
|
219
|
-
# 🎯 判断是否是弹窗元素(可选操作)
|
|
220
|
-
is_popup = self._is_popup_element(element, ref)
|
|
221
|
-
|
|
222
|
-
script_lines.append(f" # 步骤{step_index}: 点击 {element}")
|
|
223
|
-
script_lines.append(f" print(f\"\\n步骤{step_index}: 点击 {element}\", file=sys.stderr)")
|
|
224
|
-
|
|
225
|
-
# 🎯 弹窗元素:可选操作(不出现也不报错)
|
|
226
|
-
if is_popup:
|
|
227
|
-
script_lines.append(f" # 🎯 可选操作:弹窗/权限请求(不一定出现)")
|
|
228
|
-
script_lines.append(f" try:")
|
|
229
|
-
else:
|
|
230
|
-
script_lines.append(f" try:")
|
|
231
|
-
|
|
232
|
-
# 根据ref类型生成不同的优先定位代码
|
|
233
|
-
if ref.startswith('vision_coord_'):
|
|
234
|
-
# 视觉识别坐标:vision_coord_x_y
|
|
235
|
-
parts = ref.replace('vision_coord_', '').split('_')
|
|
236
|
-
if len(parts) >= 2:
|
|
237
|
-
x, y = parts[0], parts[1]
|
|
238
|
-
script_lines.append(f" # 优先使用MCP验证过的坐标")
|
|
239
|
-
script_lines.append(f" client.u2.click({x}, {y})")
|
|
240
|
-
script_lines.append(f" print(f\"✅ 点击成功(坐标: {x}, {y})\", file=sys.stderr)")
|
|
241
|
-
elif ref.startswith('[') and '][' in ref:
|
|
242
|
-
# bounds坐标:[x1,y1][x2,y2]
|
|
243
|
-
script_lines.append(f" # 优先使用MCP验证过的bounds")
|
|
244
|
-
script_lines.append(f" await client.click(\"{element}\", ref=\"{ref}\", verify=False)")
|
|
245
|
-
script_lines.append(f" print(f\"✅ 点击成功(bounds: {ref})\", file=sys.stderr)")
|
|
246
|
-
elif ref.startswith('com.') or ':' in ref:
|
|
247
|
-
# resource-id定位
|
|
248
|
-
script_lines.append(f" # 优先使用MCP验证过的resource-id")
|
|
249
|
-
script_lines.append(f" await client.click(\"{element}\", ref=\"{ref}\", verify=False)")
|
|
250
|
-
script_lines.append(f" print(f\"✅ 点击成功(resource-id: {ref})\", file=sys.stderr)")
|
|
251
|
-
else:
|
|
252
|
-
# text/description定位
|
|
253
|
-
script_lines.append(f" # 优先使用MCP验证过的text/description")
|
|
254
|
-
script_lines.append(f" await client.click(\"{element}\", ref=\"{ref}\", verify=False)")
|
|
255
|
-
script_lines.append(f" print(f\"✅ 点击成功(text: {ref})\", file=sys.stderr)")
|
|
256
|
-
|
|
257
|
-
# 添加降级逻辑(区分弹窗和普通元素)
|
|
258
|
-
if is_popup:
|
|
259
|
-
# 🎯 弹窗:失败不报错,只打印提示
|
|
260
|
-
script_lines.append(f" except Exception as e:")
|
|
261
|
-
script_lines.append(f" # 弹窗未出现,跳过")
|
|
262
|
-
script_lines.append(f" print(f\"ℹ️ '{element}'未出现,跳过(可能已授权或无需操作)\", file=sys.stderr)")
|
|
263
|
-
else:
|
|
264
|
-
# 🎯 普通元素:失败后启用智能定位
|
|
265
|
-
script_lines.append(f" except Exception as e:")
|
|
266
|
-
script_lines.append(f" # 🎯 原定位失效,启用智能定位(自愈)")
|
|
267
|
-
script_lines.append(f" print(f\"⚠️ 原定位失效: {{e}}\", file=sys.stderr)")
|
|
268
|
-
script_lines.append(f" print(f\"🔍 启用智能定位重新查找'{element}'...\", file=sys.stderr)")
|
|
269
|
-
script_lines.append(f" ")
|
|
270
|
-
script_lines.append(f" locate_result = await client.smart_locator.locate(\"{element}\")")
|
|
271
|
-
script_lines.append(f" if locate_result:")
|
|
272
|
-
script_lines.append(f" await client.click(\"{element}\", ref=locate_result['ref'], verify=False)")
|
|
273
|
-
script_lines.append(f" print(f\"✅ 智能定位成功: {{locate_result['ref']}}\", file=sys.stderr)")
|
|
274
|
-
script_lines.append(f" else:")
|
|
275
|
-
script_lines.append(f" raise Exception(f\"❌ 智能定位也失败了,元素'{element}'可能已被删除或页面结构大幅改变\")")
|
|
276
|
-
script_lines.append(f" ")
|
|
277
|
-
|
|
278
|
-
# 🎯 下拉框场景:添加等待
|
|
279
|
-
if self._is_dropdown_scenario(operation_history, op_index):
|
|
280
|
-
script_lines.append(f" await asyncio.sleep(0.5) # 🎯 等待下拉选项加载")
|
|
281
|
-
else:
|
|
282
|
-
script_lines.append(f" await asyncio.sleep(1.5) # 等待页面响应")
|
|
283
|
-
|
|
284
|
-
step_index += 1
|
|
285
|
-
|
|
286
|
-
elif action == 'type':
|
|
287
|
-
text = operation.get('text', '')
|
|
288
|
-
script_lines.append(f" # 步骤{step_index}: 在{element}输入 {text}")
|
|
289
|
-
script_lines.append(f" print(f\"\\n步骤{step_index}: 在{element}输入 {text}\", file=sys.stderr)")
|
|
290
|
-
|
|
291
|
-
# 🎯 生成智能定位 + 自动降级代码
|
|
292
|
-
script_lines.append(f" try:")
|
|
293
|
-
|
|
294
|
-
# 🎯 输入前先清空(避免内容累加)
|
|
295
|
-
script_lines.append(f" # 🎯 先点击输入框聚焦")
|
|
296
|
-
if ref.startswith('[') and '][' in ref:
|
|
297
|
-
script_lines.append(f" await client.click(\"{element}\", ref=\"{ref}\", verify=False)")
|
|
298
|
-
elif ref.startswith('com.') or ':' in ref:
|
|
299
|
-
script_lines.append(f" await client.click(\"{element}\", ref=\"{ref}\", verify=False)")
|
|
300
|
-
else:
|
|
301
|
-
script_lines.append(f" await client.click(\"{element}\", ref=\"{ref}\", verify=False)")
|
|
302
|
-
|
|
303
|
-
script_lines.append(f" await asyncio.sleep(0.3)")
|
|
304
|
-
script_lines.append(f" ")
|
|
305
|
-
script_lines.append(f" # 🎯 清空输入框(避免内容累加)")
|
|
306
|
-
script_lines.append(f" if client.platform == 'android':")
|
|
307
|
-
script_lines.append(f" client.u2.clear_text()")
|
|
308
|
-
script_lines.append(f" elif client.platform == 'ios':")
|
|
309
|
-
script_lines.append(f" # iOS清空逻辑")
|
|
310
|
-
script_lines.append(f" pass")
|
|
311
|
-
script_lines.append(f" await asyncio.sleep(0.2)")
|
|
312
|
-
script_lines.append(f" ")
|
|
313
|
-
|
|
314
|
-
# 根据ref类型生成不同的优先定位代码
|
|
315
|
-
if ref.startswith('[') and '][' in ref:
|
|
316
|
-
# bounds坐标
|
|
317
|
-
script_lines.append(f" # 优先使用MCP验证过的bounds")
|
|
318
|
-
script_lines.append(f" await client.type_text(\"{element}\", \"{text}\", ref=\"{ref}\")")
|
|
319
|
-
script_lines.append(f" print(f\"✅ 输入成功(bounds: {ref})\", file=sys.stderr)")
|
|
320
|
-
elif ref.startswith('com.') or ':' in ref:
|
|
321
|
-
# resource-id定位
|
|
322
|
-
script_lines.append(f" # 优先使用MCP验证过的resource-id")
|
|
323
|
-
script_lines.append(f" await client.type_text(\"{element}\", \"{text}\", ref=\"{ref}\")")
|
|
324
|
-
script_lines.append(f" print(f\"✅ 输入成功(resource-id: {ref})\", file=sys.stderr)")
|
|
325
|
-
else:
|
|
326
|
-
# text定位
|
|
327
|
-
script_lines.append(f" # 优先使用MCP验证过的text")
|
|
328
|
-
script_lines.append(f" await client.type_text(\"{element}\", \"{text}\", ref=\"{ref}\")")
|
|
329
|
-
script_lines.append(f" print(f\"✅ 输入成功(text: {ref})\", file=sys.stderr)")
|
|
330
|
-
|
|
331
|
-
# 添加降级逻辑
|
|
332
|
-
script_lines.append(f" except Exception as e:")
|
|
333
|
-
script_lines.append(f" # 🎯 原定位失效,启用智能定位(自愈)")
|
|
334
|
-
script_lines.append(f" print(f\"⚠️ 原定位失效: {{e}}\", file=sys.stderr)")
|
|
335
|
-
script_lines.append(f" print(f\"🔍 启用智能定位重新查找'{element}'...\", file=sys.stderr)")
|
|
336
|
-
script_lines.append(f" ")
|
|
337
|
-
script_lines.append(f" locate_result = await client.smart_locator.locate(\"{element}\")")
|
|
338
|
-
script_lines.append(f" if locate_result:")
|
|
339
|
-
script_lines.append(f" # 重新点击聚焦")
|
|
340
|
-
script_lines.append(f" await client.click(\"{element}\", ref=locate_result['ref'], verify=False)")
|
|
341
|
-
script_lines.append(f" await asyncio.sleep(0.3)")
|
|
342
|
-
script_lines.append(f" # 清空")
|
|
343
|
-
script_lines.append(f" if client.platform == 'android':")
|
|
344
|
-
script_lines.append(f" client.u2.clear_text()")
|
|
345
|
-
script_lines.append(f" await asyncio.sleep(0.2)")
|
|
346
|
-
script_lines.append(f" # 输入")
|
|
347
|
-
script_lines.append(f" await client.type_text(\"{element}\", \"{text}\", ref=locate_result['ref'])")
|
|
348
|
-
script_lines.append(f" print(f\"✅ 智能定位成功: {{locate_result['ref']}}\", file=sys.stderr)")
|
|
349
|
-
script_lines.append(f" else:")
|
|
350
|
-
script_lines.append(f" raise Exception(f\"❌ 智能定位也失败了,元素'{element}'可能已被删除或页面结构大幅改变\")")
|
|
351
|
-
script_lines.append(f" ")
|
|
352
|
-
script_lines.append(f" await asyncio.sleep(1) # 等待输入完成")
|
|
353
|
-
|
|
354
|
-
step_index += 1
|
|
355
|
-
|
|
356
|
-
# 添加结尾(pytest格式)
|
|
357
|
-
script_lines.extend([
|
|
358
|
-
f" ",
|
|
359
|
-
f" print(\"\\n✅ 测试完成!\", file=sys.stderr)",
|
|
360
|
-
f" ",
|
|
361
|
-
f" except AssertionError as e:",
|
|
362
|
-
f" print(f\"\\n❌ 断言失败: {{e}}\", file=sys.stderr)",
|
|
363
|
-
f" # 打印当前页面快照以便调试",
|
|
364
|
-
f" snapshot = await client.snapshot()",
|
|
365
|
-
f" print(f\"\\n当前页面快照:\\n{{snapshot[:500]}}...\", file=sys.stderr)",
|
|
366
|
-
f" raise",
|
|
367
|
-
f" except Exception as e:",
|
|
368
|
-
f" print(f\"\\n❌ 测试失败: {{e}}\", file=sys.stderr)",
|
|
369
|
-
f" import traceback",
|
|
370
|
-
f" traceback.print_exc()",
|
|
371
|
-
f" raise",
|
|
372
|
-
])
|
|
373
|
-
|
|
374
|
-
return '\n'.join(script_lines)
|
|
375
|
-
|
|
376
|
-
def save(self, filename: str, script: str):
|
|
377
|
-
"""
|
|
378
|
-
保存生成的测试脚本
|
|
379
|
-
|
|
380
|
-
Args:
|
|
381
|
-
filename: 文件名(会自动添加.py后缀)
|
|
382
|
-
script: 脚本内容
|
|
383
|
-
"""
|
|
384
|
-
if not filename.endswith('.py'):
|
|
385
|
-
filename += '.py'
|
|
386
|
-
|
|
387
|
-
file_path = self.output_dir / filename
|
|
388
|
-
file_path.write_text(script, encoding='utf-8')
|
|
389
|
-
print(f"✅ 测试用例已保存: {file_path}", file=sys.stderr)
|
|
390
|
-
return file_path
|
|
391
|
-
|
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
"""
|
|
4
|
-
生成独立的测试脚本 - 不依赖 mobile_mcp 包
|
|
5
|
-
|
|
6
|
-
生成纯粹基于 uiautomator2 的测试脚本,用户可以直接运行
|
|
7
|
-
"""
|
|
8
|
-
import re
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from typing import List, Dict
|
|
11
|
-
from datetime import datetime
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class StandaloneTestGenerator:
|
|
15
|
-
"""
|
|
16
|
-
生成独立的测试脚本(不依赖 mobile_mcp 包)
|
|
17
|
-
|
|
18
|
-
特点:
|
|
19
|
-
1. 只依赖 uiautomator2(用户常用库)
|
|
20
|
-
2. 使用 MCP 验证过的坐标/bounds/resource-id
|
|
21
|
-
3. 无需安装 mobile-mcp-ai 包即可运行
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
def __init__(self, output_dir: str = "./tests"):
|
|
25
|
-
"""
|
|
26
|
-
初始化生成器
|
|
27
|
-
|
|
28
|
-
Args:
|
|
29
|
-
output_dir: 输出目录(默认为当前目录的tests子目录)
|
|
30
|
-
"""
|
|
31
|
-
self.output_dir = Path(output_dir)
|
|
32
|
-
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
-
|
|
34
|
-
def generate_from_history(
|
|
35
|
-
self,
|
|
36
|
-
test_name: str,
|
|
37
|
-
package_name: str,
|
|
38
|
-
operation_history: List[Dict],
|
|
39
|
-
device_id: str = None
|
|
40
|
-
) -> str:
|
|
41
|
-
"""
|
|
42
|
-
从操作历史生成独立的测试脚本
|
|
43
|
-
|
|
44
|
-
Args:
|
|
45
|
-
test_name: 测试用例名称
|
|
46
|
-
package_name: App包名
|
|
47
|
-
operation_history: 操作历史列表
|
|
48
|
-
device_id: 设备ID(可选)
|
|
49
|
-
|
|
50
|
-
Returns:
|
|
51
|
-
生成的测试脚本内容
|
|
52
|
-
"""
|
|
53
|
-
safe_name = re.sub(r'[^\w\s-]', '', test_name).strip().replace(' ', '_')
|
|
54
|
-
|
|
55
|
-
# 生成脚本头部
|
|
56
|
-
script_lines = self._generate_header(test_name, safe_name)
|
|
57
|
-
|
|
58
|
-
# 生成导入部分
|
|
59
|
-
script_lines.extend(self._generate_imports())
|
|
60
|
-
|
|
61
|
-
# 生成常量
|
|
62
|
-
script_lines.extend([
|
|
63
|
-
f'PACKAGE_NAME = "{package_name}"',
|
|
64
|
-
f'DEVICE_ID = {repr(device_id)} # None表示自动选择第一个设备',
|
|
65
|
-
"",
|
|
66
|
-
""
|
|
67
|
-
])
|
|
68
|
-
|
|
69
|
-
# 生成 fixture
|
|
70
|
-
script_lines.extend(self._generate_fixture())
|
|
71
|
-
|
|
72
|
-
# 生成测试函数
|
|
73
|
-
script_lines.extend(self._generate_test_function(
|
|
74
|
-
test_name, safe_name, operation_history
|
|
75
|
-
))
|
|
76
|
-
|
|
77
|
-
return "\n".join(script_lines)
|
|
78
|
-
|
|
79
|
-
def _generate_header(self, test_name: str, safe_name: str) -> List[str]:
|
|
80
|
-
"""生成文件头部"""
|
|
81
|
-
return [
|
|
82
|
-
"#!/usr/bin/env python3",
|
|
83
|
-
"# -*- coding: utf-8 -*-",
|
|
84
|
-
f'"""',
|
|
85
|
-
f"移动端自动化测试: {test_name}",
|
|
86
|
-
f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
87
|
-
f"",
|
|
88
|
-
f"依赖: pip install uiautomator2 pytest pytest-asyncio",
|
|
89
|
-
f"",
|
|
90
|
-
f"运行方式:",
|
|
91
|
-
f" pytest test_{safe_name}.py -v -s",
|
|
92
|
-
f" pytest test_{safe_name}.py --alluredir=./allure-results # 生成allure报告",
|
|
93
|
-
f'"""',
|
|
94
|
-
""
|
|
95
|
-
]
|
|
96
|
-
|
|
97
|
-
def _generate_imports(self) -> List[str]:
|
|
98
|
-
"""生成导入部分"""
|
|
99
|
-
return [
|
|
100
|
-
"import time",
|
|
101
|
-
"import pytest",
|
|
102
|
-
"import uiautomator2 as u2",
|
|
103
|
-
"",
|
|
104
|
-
""
|
|
105
|
-
]
|
|
106
|
-
|
|
107
|
-
def _generate_fixture(self) -> List[str]:
|
|
108
|
-
"""生成 pytest fixture"""
|
|
109
|
-
return [
|
|
110
|
-
"@pytest.fixture(scope='function')",
|
|
111
|
-
"def device():",
|
|
112
|
-
' """',
|
|
113
|
-
" pytest fixture: 创建并返回设备连接",
|
|
114
|
-
" scope='function': 每个测试函数都会创建一个新的连接",
|
|
115
|
-
' """',
|
|
116
|
-
" # 连接设备",
|
|
117
|
-
" d = u2.connect(DEVICE_ID) # None表示自动选择第一个设备",
|
|
118
|
-
" print(f\"\\n📱 连接设备: {d.device_info}\")",
|
|
119
|
-
" ",
|
|
120
|
-
" # 启动App",
|
|
121
|
-
" print(f\"🚀 启动App: {PACKAGE_NAME}\")",
|
|
122
|
-
" d.app_start(PACKAGE_NAME, stop=True)",
|
|
123
|
-
" ",
|
|
124
|
-
" # 🎯 智能等待:App启动+首页加载(5-8秒)",
|
|
125
|
-
" print(\"⏳ 等待App启动和首页加载...\")",
|
|
126
|
-
" time.sleep(2) # 等待进程启动",
|
|
127
|
-
" ",
|
|
128
|
-
" # 等待页面稳定(检测连续2次页面内容相同)",
|
|
129
|
-
" last_xml = None",
|
|
130
|
-
" stable_count = 0",
|
|
131
|
-
" max_wait = 8 # 最多等待8秒",
|
|
132
|
-
" start_time = time.time()",
|
|
133
|
-
" ",
|
|
134
|
-
" while time.time() - start_time < max_wait:",
|
|
135
|
-
" try:",
|
|
136
|
-
" current_xml = d.dump_hierarchy()",
|
|
137
|
-
" if current_xml == last_xml:",
|
|
138
|
-
" stable_count += 1",
|
|
139
|
-
" if stable_count >= 2:",
|
|
140
|
-
" print(f\"✅ 首页加载完成({time.time() - start_time:.1f}秒)\")",
|
|
141
|
-
" break",
|
|
142
|
-
" else:",
|
|
143
|
-
" stable_count = 0",
|
|
144
|
-
" last_xml = current_xml",
|
|
145
|
-
" time.sleep(0.5)",
|
|
146
|
-
" except:",
|
|
147
|
-
" time.sleep(0.5)",
|
|
148
|
-
" ",
|
|
149
|
-
" yield d",
|
|
150
|
-
" ",
|
|
151
|
-
" # 清理(可选:关闭App)",
|
|
152
|
-
" # d.app_stop(PACKAGE_NAME)",
|
|
153
|
-
"",
|
|
154
|
-
""
|
|
155
|
-
]
|
|
156
|
-
|
|
157
|
-
def _generate_test_function(
|
|
158
|
-
self,
|
|
159
|
-
test_name: str,
|
|
160
|
-
safe_name: str,
|
|
161
|
-
operations: List[Dict]
|
|
162
|
-
) -> List[str]:
|
|
163
|
-
"""生成测试函数"""
|
|
164
|
-
lines = [
|
|
165
|
-
f"def test_{safe_name.lower()}(device):",
|
|
166
|
-
f' """',
|
|
167
|
-
f" 测试用例: {test_name}",
|
|
168
|
-
f" ",
|
|
169
|
-
f" Args:",
|
|
170
|
-
f" device: pytest fixture,已启动App的设备连接",
|
|
171
|
-
f' """',
|
|
172
|
-
f" d = device",
|
|
173
|
-
f" ",
|
|
174
|
-
]
|
|
175
|
-
|
|
176
|
-
step_index = 1
|
|
177
|
-
for op in operations:
|
|
178
|
-
action = op.get('action')
|
|
179
|
-
element = op.get('element', '')
|
|
180
|
-
ref = op.get('ref', '')
|
|
181
|
-
|
|
182
|
-
if action == 'click':
|
|
183
|
-
lines.extend(self._generate_click_code(element, ref, step_index))
|
|
184
|
-
step_index += 1
|
|
185
|
-
elif action == 'type':
|
|
186
|
-
text = op.get('text', '')
|
|
187
|
-
lines.extend(self._generate_input_code(element, ref, text, step_index))
|
|
188
|
-
step_index += 1
|
|
189
|
-
|
|
190
|
-
# 添加断言(可选)
|
|
191
|
-
lines.extend([
|
|
192
|
-
" ",
|
|
193
|
-
" # ✅ 测试完成",
|
|
194
|
-
" print(\"✅ 测试通过\")",
|
|
195
|
-
])
|
|
196
|
-
|
|
197
|
-
return lines
|
|
198
|
-
|
|
199
|
-
def _generate_click_code(self, element: str, ref: str, step: int) -> List[str]:
|
|
200
|
-
"""生成点击代码"""
|
|
201
|
-
lines = [
|
|
202
|
-
f" # 步骤{step}: 点击 {element}",
|
|
203
|
-
f" print(f\"\\n步骤{step}: 点击 {element}\")",
|
|
204
|
-
]
|
|
205
|
-
|
|
206
|
-
# 🎯 判断是否需要更长等待(页面跳转类操作)
|
|
207
|
-
is_navigation = any(keyword in element.lower() for keyword in [
|
|
208
|
-
'首页', '搜索', '返回', '确定', '提交', '登录', '注册',
|
|
209
|
-
'home', 'search', 'back', 'submit', 'login', 'register'
|
|
210
|
-
])
|
|
211
|
-
wait_time = 2.0 if is_navigation else 1.5
|
|
212
|
-
|
|
213
|
-
# 根据ref类型生成不同的点击代码
|
|
214
|
-
if ref.startswith('[') and '][' in ref:
|
|
215
|
-
# bounds坐标:[x1,y1][x2,y2]
|
|
216
|
-
import re
|
|
217
|
-
match = re.search(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', ref)
|
|
218
|
-
if match:
|
|
219
|
-
x1, y1, x2, y2 = match.groups()
|
|
220
|
-
x = (int(x1) + int(x2)) // 2
|
|
221
|
-
y = (int(y1) + int(y2)) // 2
|
|
222
|
-
lines.extend([
|
|
223
|
-
f" d.click({x}, {y}) # 使用MCP验证过的坐标",
|
|
224
|
-
f" time.sleep({wait_time}) # 等待页面响应",
|
|
225
|
-
])
|
|
226
|
-
elif ref.startswith('com.') or ':id/' in ref:
|
|
227
|
-
# resource-id
|
|
228
|
-
lines.extend([
|
|
229
|
-
f" d(resourceId=\"{ref}\").click() # 使用MCP验证过的resource-id",
|
|
230
|
-
f" time.sleep({wait_time}) # 等待页面响应",
|
|
231
|
-
])
|
|
232
|
-
else:
|
|
233
|
-
# text
|
|
234
|
-
lines.extend([
|
|
235
|
-
f" d(text=\"{ref}\").click() # 使用MCP验证过的text",
|
|
236
|
-
f" time.sleep({wait_time}) # 等待页面响应",
|
|
237
|
-
])
|
|
238
|
-
|
|
239
|
-
lines.append("")
|
|
240
|
-
return lines
|
|
241
|
-
|
|
242
|
-
def _generate_input_code(self, element: str, ref: str, text: str, step: int) -> List[str]:
|
|
243
|
-
"""生成输入代码"""
|
|
244
|
-
lines = [
|
|
245
|
-
f" # 步骤{step}: 在{element}输入 {text}",
|
|
246
|
-
f" print(f\"\\n步骤{step}: 在{element}输入 {text}\")",
|
|
247
|
-
]
|
|
248
|
-
|
|
249
|
-
if ref.startswith('com.') or ':id/' in ref:
|
|
250
|
-
# resource-id
|
|
251
|
-
lines.extend([
|
|
252
|
-
f" d(resourceId=\"{ref}\").click() # 先点击聚焦",
|
|
253
|
-
f" time.sleep(0.5) # 等待键盘弹出",
|
|
254
|
-
f" d(resourceId=\"{ref}\").clear_text() # 清空",
|
|
255
|
-
f" time.sleep(0.3)",
|
|
256
|
-
f" d(resourceId=\"{ref}\").set_text(\"{text}\") # 输入",
|
|
257
|
-
f" time.sleep(1.5) # 等待输入完成",
|
|
258
|
-
])
|
|
259
|
-
else:
|
|
260
|
-
# text
|
|
261
|
-
lines.extend([
|
|
262
|
-
f" d(text=\"{ref}\").click() # 先点击聚焦",
|
|
263
|
-
f" time.sleep(0.5) # 等待键盘弹出",
|
|
264
|
-
f" d.clear_text() # 清空",
|
|
265
|
-
f" time.sleep(0.3)",
|
|
266
|
-
f" d.send_keys(\"{text}\") # 输入",
|
|
267
|
-
f" time.sleep(1.5) # 等待输入完成",
|
|
268
|
-
])
|
|
269
|
-
|
|
270
|
-
lines.append("")
|
|
271
|
-
return lines
|
|
272
|
-
|
|
273
|
-
def save(self, filename: str, script: str) -> Path:
|
|
274
|
-
"""
|
|
275
|
-
保存脚本到文件
|
|
276
|
-
|
|
277
|
-
Args:
|
|
278
|
-
filename: 文件名(不含.py后缀)
|
|
279
|
-
script: 脚本内容
|
|
280
|
-
|
|
281
|
-
Returns:
|
|
282
|
-
保存的文件路径
|
|
283
|
-
"""
|
|
284
|
-
if not filename.endswith('.py'):
|
|
285
|
-
filename = f"{filename}.py"
|
|
286
|
-
|
|
287
|
-
file_path = self.output_dir / filename
|
|
288
|
-
with open(file_path, 'w', encoding='utf-8') as f:
|
|
289
|
-
f.write(script)
|
|
290
|
-
|
|
291
|
-
print(f"✅ 测试用例已保存: {file_path}")
|
|
292
|
-
return file_path
|
|
293
|
-
|