pdd-skills 3.1.13 → 3.2.3
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.
- package/LICENSE +21 -21
- package/package.json +1 -1
- package/scaffolds/python-fullstack/.github/workflows/ci.yml +1 -1
- package/scaffolds/python-fullstack/Dockerfile +1 -1
- package/scaffolds/python-fullstack/docker-compose.yml +1 -1
- package/scaffolds/python-fullstack/frontend/Dockerfile +1 -1
- package/scaffolds/python-fullstack/frontend/nginx.conf +1 -1
- package/scaffolds/python-fullstack/frontend/postcss.config.js +1 -1
- package/scaffolds/python-fullstack/frontend/src/api/client.ts +1 -1
- package/scaffolds/python-fullstack/frontend/src/composables/useResponsive.ts +1 -1
- package/scaffolds/python-fullstack/frontend/src/router/index.ts +1 -1
- package/scaffolds/python-fullstack/frontend/src/stores/user.ts +1 -1
- package/scaffolds/python-fullstack/frontend/src/styles/responsive.css +1 -1
- package/scaffolds/python-fullstack/frontend/src/styles/variables.css +1 -1
- package/scaffolds/python-fullstack/frontend/src/views/DashboardView.vue +1 -1
- package/scaffolds/python-fullstack/frontend/src/views/HomeView.vue +1 -1
- package/scaffolds/python-fullstack/frontend/src/views/LoginView.vue +1 -1
- package/scaffolds/python-fullstack/frontend/tailwind.config.js +1 -1
- package/skills/core/official-doc-writer/README.md +232 -232
- package/skills/core/official-doc-writer/SKILL.md +4 -7
- package/skills/core/official-doc-writer/fonts/FONTS_LIST.md +44 -44
- package/skills/core/official-doc-writer/references/GBT_9704-2012_/345/205/232/346/224/277/346/234/272/345/205/263/345/205/254/346/226/207/346/240/274/345/274/217.md +422 -422
- package/skills/core/pdd-ba/SKILL.md +1 -11
- package/skills/core/pdd-entropy-reduction/SKILL.md +1 -9
- package/skills/core/pdd-extract-features/SKILL.md +1 -10
- package/skills/core/pdd-generate-spec/SKILL.md +1 -10
- package/skills/core/pdd-main/evals/evals.json +215 -215
- package/skills/core/pdd-verify-feature/SKILL.md +1 -10
- package/skills/core/pdd-vm/SKILL.md +1 -11
- package/skills/entropy/expert-arch-enforcer/SKILL.md +292 -292
- package/skills/entropy/expert-auto-refactor/SKILL.md +316 -327
- package/skills/entropy/expert-code-quality/SKILL.md +468 -468
- package/skills/entropy/expert-entropy-auditor/SKILL.md +276 -276
- package/skills/expert/expert-activiti/SKILL.md +488 -497
- package/skills/expert/expert-bug-fixer/SKILL.md +1 -26
- package/skills/expert/expert-mysql/SKILL.md +832 -832
- package/skills/expert/expert-ruoyi/SKILL.md +664 -674
- package/skills/expert/expert-springcloud/SKILL.md +1 -10
- package/skills/expert/expert-vue3/SKILL.md +1 -10
- package/skills/expert/testcase-agent/SKILL.md +5 -0
- package/skills/expert/testcase-modeler/SKILL.md +40 -2
- package/skills/pr/pdd-multi-review/SKILL.md +534 -534
- package/skills/pr/pdd-pr-batch/SKILL.md +303 -303
- package/skills/pr/pdd-pr-create/SKILL.md +344 -344
- package/skills/pr/pdd-pr-merge/SKILL.md +286 -286
- package/skills/pr/pdd-pr-review/SKILL.md +217 -217
- package/skills/pr/pdd-task-manager/SKILL.md +636 -636
- package/skills/pr/pdd-template-engine/SKILL.md +386 -386
- package/tests/login_manager.py +5 -30
- package/tests/recorder.py +362 -294
- package/tests/testcase-ai.py +1184 -11
package/tests/testcase-ai.py
CHANGED
|
@@ -28,6 +28,23 @@ import asyncio
|
|
|
28
28
|
import json
|
|
29
29
|
import os
|
|
30
30
|
import re
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _safe_json_dumps(obj, **kwargs):
|
|
35
|
+
"""JSON序列化,自动处理非标准类型(date/datetime/bytes等)"""
|
|
36
|
+
def _default(o):
|
|
37
|
+
if hasattr(o, 'isoformat'):
|
|
38
|
+
return o.isoformat()
|
|
39
|
+
if isinstance(o, (bytes, bytearray)):
|
|
40
|
+
return o.decode('utf-8', errors='replace')
|
|
41
|
+
if hasattr(o, '__dict__'):
|
|
42
|
+
return o.__dict__
|
|
43
|
+
return str(o)
|
|
44
|
+
try:
|
|
45
|
+
return json.dumps(obj, **kwargs)
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
return json.dumps(obj, default=_default, **kwargs)
|
|
31
48
|
import shutil
|
|
32
49
|
import sys
|
|
33
50
|
import time
|
|
@@ -73,16 +90,63 @@ except ImportError:
|
|
|
73
90
|
# ============================================================
|
|
74
91
|
# MCP 配置区
|
|
75
92
|
# ============================================================
|
|
93
|
+
# Chrome DevTools MCP 连接参数(Isolated 隔离模式)
|
|
94
|
+
# ============================================================
|
|
95
|
+
|
|
96
|
+
def _create_incognito_server_params() -> StdioServerParameters:
|
|
97
|
+
"""创建使用隔离模式 Chrome 的 MCP 连接参数
|
|
98
|
+
|
|
99
|
+
chrome-devtools-mcp 通过 --chromeArg=VALUE 格式传递额外 Chrome 启动参数
|
|
100
|
+
参考: https://github.com/ChromeDevTools/chrome-devtools-mcp/pull/338
|
|
101
|
+
|
|
102
|
+
重要: --chromeArg 仅在 chrome-devtools-mcp 自己启动 Chrome 时生效,
|
|
103
|
+
如果连接到已存在的 Chrome 实例则参数会被忽略。
|
|
104
|
+
|
|
105
|
+
注意: 不使用 --incognito 参数!
|
|
106
|
+
原因: --incognito + --isolated 会导致 Chrome 打开两个窗口:
|
|
107
|
+
1) Incognito 窗口(无操作) 2) 普通窗口(实际操作在此)
|
|
108
|
+
这是因为 Puppeteer 连接的是浏览器默认目标页面而非 Incognito 页面。
|
|
109
|
+
--isolated 本身已提供隔离(临时用户数据目录),无需 --incognito。
|
|
110
|
+
"""
|
|
111
|
+
npx_args = [
|
|
112
|
+
"-y", "chrome-devtools-mcp@latest",
|
|
113
|
+
"--isolated", # 使用临时用户数据目录(隔离模式)
|
|
114
|
+
"--chromeArg=--no-first-run",
|
|
115
|
+
"--chromeArg=--no-default-browser-check",
|
|
116
|
+
"--chromeArg=--disable-sync",
|
|
117
|
+
"--chromeArg=--disable-extensions",
|
|
118
|
+
"--chromeArg=--disable-component-extensions-with-background-pages",
|
|
119
|
+
"--chromeArg=--disable-popup-blocking",
|
|
120
|
+
"--chromeArg=--ignore-certificate-errors",
|
|
121
|
+
"--chromeArg=--ignore-certificate-errors-spki-list",
|
|
122
|
+
"--chromeArg=--disable-web-security",
|
|
123
|
+
"--chromeArg=--allow-running-insecure-content",
|
|
124
|
+
"--chromeArg=--unsafely-treat-insecure-origin-as-secure",
|
|
125
|
+
"--chromeArg=--disable-password-manager-reauthentication",
|
|
126
|
+
"--chromeArg=--disable-features=PasswordLeakDetection",
|
|
127
|
+
"--chromeArg=--disable-features=SafeBrowsingPasswordProtectionTrigger",
|
|
128
|
+
"--chromeArg=--disable-save-password-bubble",
|
|
129
|
+
"--chromeArg=--password-store=basic",
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
viewport_w = os.environ.get("BROWSER_VIEWPORT_WIDTH") or os.environ.get("BROWSER_WIDTH", "1366")
|
|
133
|
+
viewport_h = os.environ.get("BROWSER_VIEWPORT_HEIGHT") or os.environ.get("BROWSER_HEIGHT", "768")
|
|
134
|
+
npx_args.append(f"--chromeArg=--window-size={viewport_w},{viewport_h}")
|
|
135
|
+
|
|
136
|
+
return StdioServerParameters(
|
|
137
|
+
name="Chrome DevTools MCP (Isolated)",
|
|
138
|
+
command="npx",
|
|
139
|
+
args=npx_args,
|
|
140
|
+
env=None,
|
|
141
|
+
)
|
|
76
142
|
|
|
77
|
-
SERVER_PARAMS = StdioServerParameters(
|
|
78
|
-
name="Chrome DevTools MCP",
|
|
79
|
-
command="npx",
|
|
80
|
-
args=["-y", "chrome-devtools-mcp@latest"],
|
|
81
|
-
env=None,
|
|
82
|
-
)
|
|
83
143
|
|
|
84
144
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
85
145
|
PROJECT_ROOT = os.path.dirname(BASE_DIR)
|
|
146
|
+
os.environ.setdefault("PROJECT_ROOT", PROJECT_ROOT)
|
|
147
|
+
|
|
148
|
+
SERVER_PARAMS = _create_incognito_server_params()
|
|
149
|
+
|
|
86
150
|
TESTCASES_ROOT = os.path.join(PROJECT_ROOT, "testcases")
|
|
87
151
|
RESULT_BASE_DIR = os.path.join(PROJECT_ROOT, "test-result")
|
|
88
152
|
ENV_FILE_PATH = os.path.join(BASE_DIR, ".env.test")
|
|
@@ -245,6 +309,127 @@ def resolve_env_vars(value):
|
|
|
245
309
|
return re.sub(r"\$\{([^}]+)\}", replacer, value)
|
|
246
310
|
|
|
247
311
|
|
|
312
|
+
def _resolve_includes(testcase: Dict, base_dir: str, depth: int = 0) -> Dict:
|
|
313
|
+
"""解析 _include 字段,将共享步骤合并到当前测试用例
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
testcase: 已加载的 YAML 字典
|
|
317
|
+
base_dir: 基础目录(用于解析相对路径)
|
|
318
|
+
depth: 当前递归深度(防止循环引用,最大3层)
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
合并后的 testcase 字典(steps 已包含被引用文件的步骤)
|
|
322
|
+
"""
|
|
323
|
+
indent = " " * depth
|
|
324
|
+
current_file = testcase.get("test_id", "?")
|
|
325
|
+
|
|
326
|
+
from pathlib import Path
|
|
327
|
+
|
|
328
|
+
log(f"{indent}[_include] 开始解析 | 文件={current_file} | 深度={depth}", 1)
|
|
329
|
+
|
|
330
|
+
if depth > 3:
|
|
331
|
+
log(f"{indent}[_include] ❌ 超过最大嵌套深度(3),停止递归", 2)
|
|
332
|
+
return testcase
|
|
333
|
+
|
|
334
|
+
include_path = testcase.get("_include")
|
|
335
|
+
if not include_path:
|
|
336
|
+
log(f"{indent}[_include] 无 _include 字段,跳过", 3)
|
|
337
|
+
return testcase
|
|
338
|
+
|
|
339
|
+
if not isinstance(include_path, str):
|
|
340
|
+
log(f"{indent}[_include] ❌ _include 必须是字符串路径,实际类型={type(include_path).__name__}", 2)
|
|
341
|
+
return testcase
|
|
342
|
+
|
|
343
|
+
resolved_path = Path(base_dir) / include_path
|
|
344
|
+
abs_path = str(resolved_path.resolve())
|
|
345
|
+
|
|
346
|
+
if not resolved_path.exists():
|
|
347
|
+
log(f"{indent}[_include] ❌ 引用文件不存在: {abs_path}", 1)
|
|
348
|
+
del testcase["_include"]
|
|
349
|
+
return testcase
|
|
350
|
+
|
|
351
|
+
log(f"{indent}[_include] 📂 加载共享模块:", 1)
|
|
352
|
+
log(f"{indent} 路径: {abs_path}", 1)
|
|
353
|
+
|
|
354
|
+
with open(resolved_path, "r", encoding="utf-8") as _f:
|
|
355
|
+
included = yaml.safe_load(_f)
|
|
356
|
+
|
|
357
|
+
if not included or not isinstance(included, dict):
|
|
358
|
+
log(f"{indent}[_include] ❌ 引用文件格式错误(非dict或空): {include_path}", 2)
|
|
359
|
+
del testcase["_include"]
|
|
360
|
+
return testcase
|
|
361
|
+
|
|
362
|
+
included_id = included.get("test_id", "?")
|
|
363
|
+
log(f"{indent}[_include] 📋 共享模块 ID: {included_id}", 2)
|
|
364
|
+
|
|
365
|
+
included = _resolve_includes(included, str(resolved_path.parent), depth + 1)
|
|
366
|
+
|
|
367
|
+
included_steps = included.get("steps", [])
|
|
368
|
+
current_steps = testcase.get("steps", [])
|
|
369
|
+
|
|
370
|
+
log(f"{indent}[_include] ── 合并前统计 ──", 1)
|
|
371
|
+
log(f"{indent} 共享模块({included_id}) 步骤数: {len(included_steps)}", 1)
|
|
372
|
+
log(f"{indent} 当前文件({current_file}) 步骤数: {len(current_steps)}", 1)
|
|
373
|
+
|
|
374
|
+
if included_steps:
|
|
375
|
+
log(f"{indent} 共享步骤明细:", 2)
|
|
376
|
+
for i, s in enumerate(included_steps):
|
|
377
|
+
desc = s.get("desc", s.get("target", "?"))
|
|
378
|
+
action = s.get("action", "?")
|
|
379
|
+
step_num = s.get("step", "?")
|
|
380
|
+
log(f"{indent} [{step_num}] {action}: {desc}", 2)
|
|
381
|
+
|
|
382
|
+
if current_steps:
|
|
383
|
+
log(f"{indent} 当前文件步骤明细:", 2)
|
|
384
|
+
for i, s in enumerate(current_steps):
|
|
385
|
+
desc = s.get("desc", s.get("target", "?"))
|
|
386
|
+
action = s.get("action", "?")
|
|
387
|
+
step_num = s.get("step", "?")
|
|
388
|
+
log(f"{indent} [{step_num}] {action}: {desc}", 2)
|
|
389
|
+
|
|
390
|
+
merged_steps = list(included_steps) + list(current_steps)
|
|
391
|
+
step_offset = len(included_steps)
|
|
392
|
+
|
|
393
|
+
renumbered = []
|
|
394
|
+
for idx, s in enumerate(merged_steps):
|
|
395
|
+
old_num = s.get("step", 0)
|
|
396
|
+
new_num = old_num
|
|
397
|
+
|
|
398
|
+
source_tag = ""
|
|
399
|
+
if idx < step_offset:
|
|
400
|
+
source_tag = f"[共享:{included_id}]"
|
|
401
|
+
else:
|
|
402
|
+
source_tag = "[本文件]"
|
|
403
|
+
if old_num:
|
|
404
|
+
try:
|
|
405
|
+
new_num = int(old_num) + step_offset
|
|
406
|
+
s["step"] = new_num
|
|
407
|
+
except (TypeError, ValueError):
|
|
408
|
+
pass
|
|
409
|
+
|
|
410
|
+
desc = s.get("desc", s.get("target", "?"))
|
|
411
|
+
action = s.get("action", "?")
|
|
412
|
+
|
|
413
|
+
renumbered.append(f" #{new_num} {source_tag} {action}: {desc}")
|
|
414
|
+
|
|
415
|
+
log(f"{indent} → #{new_num} {source_tag} "
|
|
416
|
+
f"action={action} target='{s.get('target', '')}' "
|
|
417
|
+
f"(原编号={old_num})", 3)
|
|
418
|
+
|
|
419
|
+
testcase["steps"] = merged_steps
|
|
420
|
+
testcase["_included_from"] = include_path
|
|
421
|
+
del testcase["_include"]
|
|
422
|
+
|
|
423
|
+
log(f"{indent}[_include] ✅ 合并完成:", 1)
|
|
424
|
+
log(f"{indent} 总计: {len(merged_steps)} 步 (共享{len(included_steps)} + 本文件{len(current_steps)})", 1)
|
|
425
|
+
log(f"{indent} 步骤偏移量: +{step_offset}", 2)
|
|
426
|
+
log(f"{indent} 最终步骤序列:", 2)
|
|
427
|
+
for line in renumbered:
|
|
428
|
+
log(f"{indent} {line}", 2)
|
|
429
|
+
|
|
430
|
+
return testcase
|
|
431
|
+
|
|
432
|
+
|
|
248
433
|
def _load_env_from_file():
|
|
249
434
|
"""从 .env 文件加载环境变量(优先级:.env.local > .env.test)"""
|
|
250
435
|
import re as _re
|
|
@@ -887,7 +1072,7 @@ class ThinkChainEngine:
|
|
|
887
1072
|
"""格式化执行结果"""
|
|
888
1073
|
parts = [
|
|
889
1074
|
f"- MCP 工具: {result.mcp_tool}",
|
|
890
|
-
f"- 参数: {
|
|
1075
|
+
f"- 参数: {_safe_json_dumps(result.mcp_args, ensure_ascii=False)[:200]}",
|
|
891
1076
|
]
|
|
892
1077
|
if result.output:
|
|
893
1078
|
output_preview = result.output[:300] + "..." if len(result.output) > 300 else result.output
|
|
@@ -1553,6 +1738,7 @@ def _resolve_uid(step: Dict, parser, cache, prefer_role: Optional[str] = None,
|
|
|
1553
1738
|
require_interactive: Optional[bool] = None) -> Optional[str]:
|
|
1554
1739
|
"""统一UID解析(优先级从高到低):
|
|
1555
1740
|
P0: locator.uid YAML强制定位,零开销
|
|
1741
|
+
P0.5: locator.aria_label 通过aria-label属性定位(用于暴露后的隐藏元素)
|
|
1556
1742
|
P1: target精确匹配 target文本与页面元素文本包含匹配
|
|
1557
1743
|
P2: desc兜底 用步骤描述做模糊匹配
|
|
1558
1744
|
P3: text_contains 最宽松的文本包含搜索
|
|
@@ -1567,6 +1753,16 @@ def _resolve_uid(step: Dict, parser, cache, prefer_role: Optional[str] = None,
|
|
|
1567
1753
|
else:
|
|
1568
1754
|
log(f"[Direct-UID-FAIL] uid={direct_uid} 不在当前页面元素中,降级到target匹配", 2)
|
|
1569
1755
|
|
|
1756
|
+
aria_label = locator.get("aria-label") or locator.get("aria_label")
|
|
1757
|
+
if aria_label:
|
|
1758
|
+
for uid, elem in parser.elements.items():
|
|
1759
|
+
elem_aria = getattr(elem, 'aria_label', None) or ''
|
|
1760
|
+
if (elem.text and aria_label.lower() in elem.text.lower()) or \
|
|
1761
|
+
(elem.name and aria_label.lower() in elem.name.lower()):
|
|
1762
|
+
log(f"[ARIA-LABEL] '{aria_label}' -> {uid} (text='{elem.text[:30] if elem.text else ''}')", 2)
|
|
1763
|
+
return uid
|
|
1764
|
+
log(f"[ARIA-LABEL-FAIL] '{aria_label}' 未找到匹配元素", 2)
|
|
1765
|
+
|
|
1570
1766
|
if require_interactive is None:
|
|
1571
1767
|
action = (step.get("action") or "").lower()
|
|
1572
1768
|
require_interactive = action in (
|
|
@@ -1709,7 +1905,7 @@ def _build_scroll_args(action, step, parser, cache) -> Dict:
|
|
|
1709
1905
|
return {"function": scripts.get(direction, "() => window.scrollTo(0, 0)")}
|
|
1710
1906
|
|
|
1711
1907
|
def _build_script_args(action, step, parser, cache) -> Dict:
|
|
1712
|
-
fn = step.get("function", step.get("script", "() => {}"))
|
|
1908
|
+
fn = step.get("function", step.get("script", step.get("value", "() => {}")))
|
|
1713
1909
|
return {"function": fn}
|
|
1714
1910
|
|
|
1715
1911
|
def _build_select_page_args(action, step, parser, cache) -> Dict:
|
|
@@ -1742,6 +1938,10 @@ def register_builtin_actions():
|
|
|
1742
1938
|
("choose", "fill", _build_select_option_args, True),
|
|
1743
1939
|
("upload_file", "upload_file", _build_upload_args, True),
|
|
1744
1940
|
("upload", "upload_file", _build_upload_args, True),
|
|
1941
|
+
("el_upload", "el_upload", _build_upload_args, True),
|
|
1942
|
+
("el_upload_file", "el_upload", _build_upload_args, True),
|
|
1943
|
+
("el_date", "fill", _build_fill_args, True),
|
|
1944
|
+
("el_date_picker", "fill", _build_fill_args, True),
|
|
1745
1945
|
("hover", "hover", _build_hover_args, True),
|
|
1746
1946
|
("drag_drop", "drag", _build_drag_args, True),
|
|
1747
1947
|
("press_key", "press_key", _build_press_key_args, False),
|
|
@@ -1943,6 +2143,242 @@ class ActionExecutor:
|
|
|
1943
2143
|
else:
|
|
1944
2144
|
self.think_engine = None
|
|
1945
2145
|
|
|
2146
|
+
async def _dismiss_password_dialog(self):
|
|
2147
|
+
"""自动关闭 Chrome 密码泄露检测弹窗("更改您的密码"提示框)
|
|
2148
|
+
|
|
2149
|
+
Chrome 的 Password Leak Detection / Safe Browsing 功能会在检测到
|
|
2150
|
+
"不安全密码"时弹出模态对话框,阻塞后续操作。
|
|
2151
|
+
--chromeArg 参数无法完全禁用此功能,需要通过 JS 主动关闭。
|
|
2152
|
+
|
|
2153
|
+
关闭策略(按优先级):
|
|
2154
|
+
1. 点击弹窗的关闭按钮 (X) 或 确定按钮
|
|
2155
|
+
2. 按 Escape 键关闭
|
|
2156
|
+
3. 通过 JS 移除弹窗 DOM 元素
|
|
2157
|
+
"""
|
|
2158
|
+
dismiss_js = """
|
|
2159
|
+
(() => {
|
|
2160
|
+
const results = [];
|
|
2161
|
+
|
|
2162
|
+
const passwordKeywords = ['更改您的密码', '密码泄露', 'PasswordLeakDetection',
|
|
2163
|
+
'password-leak-detection', 'Change your password'];
|
|
2164
|
+
|
|
2165
|
+
const selectors = [
|
|
2166
|
+
'[role="dialog"]',
|
|
2167
|
+
'[role="alertdialog"]',
|
|
2168
|
+
'.bubble-content',
|
|
2169
|
+
'.password-bubble',
|
|
2170
|
+
'.leak-detection-dialog',
|
|
2171
|
+
'#bubble-box',
|
|
2172
|
+
'cr-bubble',
|
|
2173
|
+
'.md-ripple',
|
|
2174
|
+
'[aria-label*="密码"]',
|
|
2175
|
+
'[aria-label*="password"]',
|
|
2176
|
+
];
|
|
2177
|
+
|
|
2178
|
+
let dialogFound = false;
|
|
2179
|
+
for (const sel of selectors) {
|
|
2180
|
+
const el = document.querySelector(sel);
|
|
2181
|
+
if (!el) continue;
|
|
2182
|
+
|
|
2183
|
+
const text = el.textContent || '';
|
|
2184
|
+
const hasKeyword = passwordKeywords.some(k => text.includes(k));
|
|
2185
|
+
|
|
2186
|
+
if (hasKeyword || sel.includes('password') || sel.includes('leak') || sel === 'cr-bubble') {
|
|
2187
|
+
results.push({selector: sel, found: true, text: text.substring(0, 100)});
|
|
2188
|
+
|
|
2189
|
+
const closeBtns = el.querySelectorAll(
|
|
2190
|
+
'[aria-label="关闭"], [aria-label="Close"], .close-button, ' +
|
|
2191
|
+
'.icon-close, button[id*="close"], [class*="close"]'
|
|
2192
|
+
);
|
|
2193
|
+
if (closeBtns.length > 0) {
|
|
2194
|
+
closeBtns[0].click();
|
|
2195
|
+
results.push({action: 'click_close_button'});
|
|
2196
|
+
dialogFound = true;
|
|
2197
|
+
break;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
const confirmBtns = el.querySelectorAll(
|
|
2201
|
+
'[aria-label="确定"], [aria-label="OK"], ' +
|
|
2202
|
+
'button[type="submit"], .confirm-button, .primary-button'
|
|
2203
|
+
);
|
|
2204
|
+
if (confirmBtns.length > 0) {
|
|
2205
|
+
confirmBtns[0].click();
|
|
2206
|
+
results.push({action: 'click_confirm_button'});
|
|
2207
|
+
dialogFound = true;
|
|
2208
|
+
break;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
el.style.display = 'none';
|
|
2212
|
+
el.setAttribute('aria-hidden', 'true');
|
|
2213
|
+
results.push({action: 'hide_element'});
|
|
2214
|
+
dialogFound = true;
|
|
2215
|
+
break;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
if (!dialogFound) {
|
|
2220
|
+
const allElements = document.querySelectorAll('*');
|
|
2221
|
+
for (const el of allElements) {
|
|
2222
|
+
if (el.children.length > 5) continue;
|
|
2223
|
+
const text = (el.textContent || '').trim();
|
|
2224
|
+
if ((text.includes('更改您的密码') || text.includes('密码泄露')) &&
|
|
2225
|
+
text.length < 500) {
|
|
2226
|
+
el.style.display = 'none';
|
|
2227
|
+
results.push({
|
|
2228
|
+
action: 'hide_by_text',
|
|
2229
|
+
tag: el.tagName,
|
|
2230
|
+
text: text.substring(0, 80)
|
|
2231
|
+
});
|
|
2232
|
+
dialogFound = true;
|
|
2233
|
+
break;
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
return {dismissed: dialogFound, details: results};
|
|
2239
|
+
})()
|
|
2240
|
+
"""
|
|
2241
|
+
try:
|
|
2242
|
+
result = await self.session.call_tool("evaluate_script", {"function": dismiss_js})
|
|
2243
|
+
if result and hasattr(result, 'content'):
|
|
2244
|
+
for c in result.content:
|
|
2245
|
+
if hasattr(c, 'text'):
|
|
2246
|
+
import json
|
|
2247
|
+
try:
|
|
2248
|
+
data = json.loads(c.text)
|
|
2249
|
+
if data.get('dismissed'):
|
|
2250
|
+
log(f" 🔒 已关闭密码泄露弹窗: {data.get('details', [])}", 2)
|
|
2251
|
+
except (json.JSONDecodeError, TypeError):
|
|
2252
|
+
pass
|
|
2253
|
+
except Exception as e:
|
|
2254
|
+
log(f" 🔒 密码弹窗检查完成 (无弹窗或已关闭)", 3)
|
|
2255
|
+
|
|
2256
|
+
async def _disable_auto_save(self):
|
|
2257
|
+
"""禁用被测应用表单页的自动保存/草稿保存机制
|
|
2258
|
+
|
|
2259
|
+
某些 Vue/若依应用在表单页面会启动 setInterval 或 watch 定时器,
|
|
2260
|
+
在用户操作后自动触发"保存草稿",导致页面跳转回列表页。
|
|
2261
|
+
|
|
2262
|
+
本方法通过注入 JS 来:
|
|
2263
|
+
1. 劫持 XMLHttpRequest/fetch 的保存接口调用
|
|
2264
|
+
2. 隐藏或禁用"保存草稿"按钮
|
|
2265
|
+
3. 拦截 beforeunload 事件防止意外离开
|
|
2266
|
+
4. 阻止自动跳转到列表页
|
|
2267
|
+
"""
|
|
2268
|
+
disable_js = """
|
|
2269
|
+
(() => {
|
|
2270
|
+
const results = {xhrIntercepted: false, buttonDisabled: false};
|
|
2271
|
+
|
|
2272
|
+
const saveKeywords = ['saveDraft', 'save-draft', 'autoSave', 'auto-save',
|
|
2273
|
+
'/draft', '/save', '保存', '草稿', 'draft'];
|
|
2274
|
+
|
|
2275
|
+
// 劫持 XMLHttpRequest,拦截保存草稿的 API 调用
|
|
2276
|
+
const originalOpen = XMLHttpRequest.prototype.open;
|
|
2277
|
+
const originalSend = XMLHttpRequest.prototype.send;
|
|
2278
|
+
|
|
2279
|
+
XMLHttpRequest.prototype.open = function(method, url, ...args) {
|
|
2280
|
+
this._url = url || '';
|
|
2281
|
+
this._method = method || '';
|
|
2282
|
+
return originalOpen.call(this, method, url, ...args);
|
|
2283
|
+
};
|
|
2284
|
+
|
|
2285
|
+
XMLHttpRequest.prototype.send = function(...args) {
|
|
2286
|
+
const url = this._url || '';
|
|
2287
|
+
const isSaveCall = saveKeywords.some(k =>
|
|
2288
|
+
url.toLowerCase().includes(k.toLowerCase())
|
|
2289
|
+
);
|
|
2290
|
+
|
|
2291
|
+
if (isSaveCall) {
|
|
2292
|
+
results.xhrIntercepted = true;
|
|
2293
|
+
results.interceptedUrl = url;
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
return originalSend.call(this, ...args);
|
|
2297
|
+
};
|
|
2298
|
+
|
|
2299
|
+
// 劫持 fetch API
|
|
2300
|
+
const originalFetch = window.fetch;
|
|
2301
|
+
window.fetch = function(url, options) {
|
|
2302
|
+
const urlStr = typeof url === 'string' ? url : (url && url.url ? url.url : '');
|
|
2303
|
+
const isSaveCall = saveKeywords.some(k =>
|
|
2304
|
+
urlStr.toLowerCase().includes(k.toLowerCase())
|
|
2305
|
+
);
|
|
2306
|
+
|
|
2307
|
+
if (isSaveCall) {
|
|
2308
|
+
results.fetchIntercepted = true;
|
|
2309
|
+
results.fetchUrl = urlStr;
|
|
2310
|
+
return Promise.resolve(new Response(JSON.stringify({code: 200, msg: 'intercepted'}),
|
|
2311
|
+
{status: 200, headers: {'Content-Type': 'application/json'}}));
|
|
2312
|
+
}
|
|
2313
|
+
return originalFetch.call(window, url, options);
|
|
2314
|
+
};
|
|
2315
|
+
|
|
2316
|
+
// 隐藏"保存草稿"按钮
|
|
2317
|
+
const allButtons = document.querySelectorAll('button');
|
|
2318
|
+
for (const btn of allButtons) {
|
|
2319
|
+
const text = (btn.textContent || '').trim();
|
|
2320
|
+
if (text === '保存草稿' || text === '保存' || text.includes('草稿')) {
|
|
2321
|
+
btn.style.display = 'none';
|
|
2322
|
+
btn.disabled = true;
|
|
2323
|
+
btn.setAttribute('data-auto-save-disabled', 'true');
|
|
2324
|
+
results.buttonDisabled = true;
|
|
2325
|
+
results.hiddenButton = text;
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
window.onbeforeunload = null;
|
|
2330
|
+
|
|
2331
|
+
// 阻止自动跳转到列表页
|
|
2332
|
+
const originalAssign = window.location.assign.bind(window.location);
|
|
2333
|
+
const originalReplace = window.location.replace.bind(window.location);
|
|
2334
|
+
const listPagePatterns = ['/apply-list', '/list', 'apply-list'];
|
|
2335
|
+
|
|
2336
|
+
window.location.assign = function(url) {
|
|
2337
|
+
if (listPagePatterns.some(p => url.includes(p))) {
|
|
2338
|
+
results.navigationBlocked = url;
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
return originalAssign(url);
|
|
2342
|
+
};
|
|
2343
|
+
|
|
2344
|
+
window.location.replace = function(url) {
|
|
2345
|
+
if (listPagePatterns.some(p => url.includes(p))) {
|
|
2346
|
+
results.replaceBlocked = url;
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
return originalReplace(url);
|
|
2350
|
+
};
|
|
2351
|
+
|
|
2352
|
+
window.__autoSaveDisabled = true;
|
|
2353
|
+
results.success = true;
|
|
2354
|
+
return results;
|
|
2355
|
+
})()
|
|
2356
|
+
"""
|
|
2357
|
+
try:
|
|
2358
|
+
result = await self.session.call_tool("evaluate_script", {"function": disable_js})
|
|
2359
|
+
if result and hasattr(result, 'content'):
|
|
2360
|
+
for c in result.content:
|
|
2361
|
+
if hasattr(c, 'text'):
|
|
2362
|
+
import json
|
|
2363
|
+
try:
|
|
2364
|
+
data = json.loads(c.text)
|
|
2365
|
+
parts = []
|
|
2366
|
+
if data.get('xhrIntercepted'):
|
|
2367
|
+
parts.append(f"XHR拦截({data.get('interceptedUrl', '')})")
|
|
2368
|
+
if data.get('fetchIntercepted'):
|
|
2369
|
+
parts.append(f"Fetch拦截({data.get('fetchUrl', '')})")
|
|
2370
|
+
if data.get('buttonDisabled'):
|
|
2371
|
+
parts.append(f"按钮隐藏({data.get('hiddenButton', '')})")
|
|
2372
|
+
if data.get('success') and not parts:
|
|
2373
|
+
parts.append("防护机制已注入")
|
|
2374
|
+
|
|
2375
|
+
if parts:
|
|
2376
|
+
log(f" 🛡️ 自动保存已禁用: {' | '.join(parts)}", 2)
|
|
2377
|
+
except (json.JSONDecodeError, TypeError):
|
|
2378
|
+
pass
|
|
2379
|
+
except Exception as e:
|
|
2380
|
+
log(f" 🛡️ 自动保存禁用脚本执行完成", 3)
|
|
2381
|
+
|
|
1946
2382
|
async def execute(self, step: Dict[str, Any], step_num: int,
|
|
1947
2383
|
testcase_config: Dict[str, Any] = None) -> StepResult:
|
|
1948
2384
|
"""执行单个测试步骤(含重试 + 思维链)"""
|
|
@@ -1959,9 +2395,38 @@ class ActionExecutor:
|
|
|
1959
2395
|
log(f"[步骤{step_num}] {desc}", 1)
|
|
1960
2396
|
log(f" 动作: {action_type} | 重试上限: {max_retries} | 继续执行: {continue_on_error}", 1)
|
|
1961
2397
|
|
|
2398
|
+
await self._dismiss_password_dialog()
|
|
2399
|
+
|
|
2400
|
+
if not getattr(self, '_auto_save_disabled', False):
|
|
2401
|
+
try:
|
|
2402
|
+
url_result = await self.session.call_tool("evaluate_script", {
|
|
2403
|
+
"function": "() => window.location.href"
|
|
2404
|
+
})
|
|
2405
|
+
current_url = ""
|
|
2406
|
+
if url_result and hasattr(url_result, 'content'):
|
|
2407
|
+
for c in url_result.content:
|
|
2408
|
+
if hasattr(c, 'text') and c.text.startswith('http'):
|
|
2409
|
+
current_url = c.text
|
|
2410
|
+
break
|
|
2411
|
+
|
|
2412
|
+
form_url_patterns = ['/apply', '/add', '/edit', '/form', '/create']
|
|
2413
|
+
is_form_page = any(p in current_url for p in form_url_patterns)
|
|
2414
|
+
|
|
2415
|
+
if is_form_page:
|
|
2416
|
+
await self._disable_auto_save()
|
|
2417
|
+
self._auto_save_disabled = True
|
|
2418
|
+
except Exception:
|
|
2419
|
+
pass
|
|
2420
|
+
|
|
1962
2421
|
if action_type == "assert_multiple":
|
|
1963
2422
|
return await self._execute_assert_multiple(step, step_num, desc, start_time)
|
|
1964
2423
|
|
|
2424
|
+
if action_type in ("el_upload", "el_upload_file"):
|
|
2425
|
+
return await self._execute_el_upload(step, step_num, desc, start_time)
|
|
2426
|
+
|
|
2427
|
+
if action_type in ("el_date", "el_date_picker"):
|
|
2428
|
+
return await self._execute_el_date(step, step_num, desc, start_time)
|
|
2429
|
+
|
|
1965
2430
|
mcp_entry = ActionRegistry.get(action_type)
|
|
1966
2431
|
|
|
1967
2432
|
if not mcp_entry:
|
|
@@ -2034,7 +2499,7 @@ class ActionExecutor:
|
|
|
2034
2499
|
mcp_args["includeSnapshot"] = False
|
|
2035
2500
|
|
|
2036
2501
|
log(f" 🔧 MCP工具: {mcp_tool_name}", 2)
|
|
2037
|
-
log(f" 📝 参数: {
|
|
2502
|
+
log(f" 📝 参数: {_safe_json_dumps(mcp_args, ensure_ascii=False, indent=2)}", 2)
|
|
2038
2503
|
|
|
2039
2504
|
readonly_picker_result = None
|
|
2040
2505
|
if action_type in ("select_option", "select", "choose") and "uid" in mcp_args:
|
|
@@ -2551,6 +3016,621 @@ class ActionExecutor:
|
|
|
2551
3016
|
return pu
|
|
2552
3017
|
return None
|
|
2553
3018
|
|
|
3019
|
+
async def _execute_el_upload(self, step: Dict, step_num: int,
|
|
3020
|
+
desc: str, start_time: float) -> StepResult:
|
|
3021
|
+
"""Element UI el-upload 文件上传(增强版方法2 - 一步完成)
|
|
3022
|
+
|
|
3023
|
+
内部自动执行三步操作:
|
|
3024
|
+
1. click 上传按钮
|
|
3025
|
+
2. execute_script 暴露隐藏的 input[type=file]
|
|
3026
|
+
3. upload_file 上传文件
|
|
3027
|
+
|
|
3028
|
+
YAML 用法:
|
|
3029
|
+
- step: N
|
|
3030
|
+
action: el_upload
|
|
3031
|
+
target: 上传按钮文本或uid
|
|
3032
|
+
path: C:\\path\\to\\file.docx
|
|
3033
|
+
file_label: 核准申请文件 # 可选,用于定位文件输入框所在行
|
|
3034
|
+
_locator:
|
|
3035
|
+
uid: "60_370" # 可选,上传按钮的uid
|
|
3036
|
+
wait_after:
|
|
3037
|
+
type: time
|
|
3038
|
+
duration: 3000
|
|
3039
|
+
"""
|
|
3040
|
+
log(f" 📎 [el_upload] Element UI 文件上传开始", 1)
|
|
3041
|
+
|
|
3042
|
+
target = step.get("target", "")
|
|
3043
|
+
file_path = resolve_env_vars(step.get("path", ""))
|
|
3044
|
+
file_label = step.get("file_label", step.get("row_label", ""))
|
|
3045
|
+
locator = step.get("_locator", {}) or {}
|
|
3046
|
+
button_uid = locator.get("uid")
|
|
3047
|
+
wait_after = step.get("wait_after", {})
|
|
3048
|
+
wait_duration = int(wait_after.get("duration", 2000)) if wait_after else 2000
|
|
3049
|
+
|
|
3050
|
+
if not file_path:
|
|
3051
|
+
elapsed = int((time.time() - start_time) * 1000)
|
|
3052
|
+
return StepResult(
|
|
3053
|
+
step_num=step_num, desc=desc, action="el_upload",
|
|
3054
|
+
status=StepStatus.ERROR, mcp_tool="(el_upload)",
|
|
3055
|
+
error="Missing required parameter: 'path' (file path to upload)",
|
|
3056
|
+
duration_ms=elapsed,
|
|
3057
|
+
)
|
|
3058
|
+
|
|
3059
|
+
log(f" 📎 [el_upload] 文件路径: {file_path}", 2)
|
|
3060
|
+
if file_label:
|
|
3061
|
+
log(f" 📎 [el_upload] 文件标签(行定位): {file_label}", 2)
|
|
3062
|
+
if button_uid:
|
|
3063
|
+
log(f" 📎 [el_upload] 按钮UID: {button_uid}", 2)
|
|
3064
|
+
|
|
3065
|
+
sub_steps = []
|
|
3066
|
+
|
|
3067
|
+
try:
|
|
3068
|
+
await self._take_snapshot()
|
|
3069
|
+
await asyncio.sleep(0.3)
|
|
3070
|
+
|
|
3071
|
+
step_start_time = time.time()
|
|
3072
|
+
log(f" 📎 ═════════════════════ el_upload 开始 ═════════════════════", 1)
|
|
3073
|
+
log(f" 📎 📋 参数: target='{target}' | file_label='{file_label}'", 2)
|
|
3074
|
+
log(f" 📎 📁 文件: {os.path.basename(file_path)} (存在:{os.path.isfile(file_path)})", 2)
|
|
3075
|
+
log(f" 📎 🔖 UID: {button_uid or '(未指定)'} | 等待: {wait_duration}ms | 元素数: {len(self.parser.elements)}", 2)
|
|
3076
|
+
|
|
3077
|
+
url_before = getattr(self, 'last_snapshot_url', '') or ''
|
|
3078
|
+
log(f" 📎 🌐 执行前URL: {url_before} | 耗时: {(time.time()-step_start_time)*1000:.0f}ms", 2)
|
|
3079
|
+
|
|
3080
|
+
if url_before and ('/apply-list' in url_before or '/list' in url_before) and '/apply' not in url_before:
|
|
3081
|
+
log(f" 📎 ⚠️ ═════ 检测到已在列表页!尝试提前恢复表单 ═════", 1)
|
|
3082
|
+
try:
|
|
3083
|
+
await self._take_snapshot()
|
|
3084
|
+
new_btn_uid = None
|
|
3085
|
+
for uid, elem in self.parser.elements.items():
|
|
3086
|
+
elem_text = (elem.text or "") + (getattr(elem, 'description', '') or "")
|
|
3087
|
+
if elem.role == "button" and "新增" in elem_text:
|
|
3088
|
+
new_btn_uid = uid
|
|
3089
|
+
break
|
|
3090
|
+
if new_btn_uid:
|
|
3091
|
+
log(f" 📎 🔧 找到'新增'按钮 uid={new_btn_uid},点击打开新表单...", 2)
|
|
3092
|
+
await self.session.call_tool("click", {"uid": new_btn_uid, "includeSnapshot": False})
|
|
3093
|
+
await asyncio.sleep(2000 / 1000.0)
|
|
3094
|
+
await self._take_snapshot()
|
|
3095
|
+
url_before = getattr(self, 'last_snapshot_url', '') or ''
|
|
3096
|
+
log(f" 📎 ✅ 表单页已恢复: {url_before}", 1)
|
|
3097
|
+
sub_steps.append("🔧提前恢复表单: ✅")
|
|
3098
|
+
else:
|
|
3099
|
+
log(f" 📎 ⚠️ 未找到'新增'按钮,继续执行(可能失败)", 2)
|
|
3100
|
+
sub_steps.append("🔧提前恢复: ❌未找到新增按钮")
|
|
3101
|
+
except Exception as pre_err:
|
|
3102
|
+
log(f" 📎 ⚠️ 提前恢复异常: {pre_err}", 2)
|
|
3103
|
+
sub_steps.append(f"🔧提前恢复异常: {pre_err}")
|
|
3104
|
+
|
|
3105
|
+
sub_step_1 = f"[1/3] 点击上传按钮"
|
|
3106
|
+
log(f" 📎 ── {sub_step_1} ──", 2)
|
|
3107
|
+
|
|
3108
|
+
click_uid = None
|
|
3109
|
+
if button_uid:
|
|
3110
|
+
if button_uid in self.parser.elements:
|
|
3111
|
+
click_uid = button_uid
|
|
3112
|
+
log(f" 📎 ✅ [P1-UID直击] 使用指定UID: {button_uid}", 2)
|
|
3113
|
+
else:
|
|
3114
|
+
log(f" 📎 🔄 [UID漂移] '{button_uid}' 不在快照({len(self.parser.elements)}个元素)中,启动SmartMatch...", 2)
|
|
3115
|
+
click_uid = self._find_upload_button_uid(target, button_uid, file_label)
|
|
3116
|
+
if click_uid:
|
|
3117
|
+
log(f" 📎 ✅ [P2-SmartMatch] 后缀匹配成功: {button_uid} → {click_uid} (耗时:{(time.time()-step_start_time)*1000:.0f}ms)", 1)
|
|
3118
|
+
else:
|
|
3119
|
+
log(f" 📎 ⚠️ [P2-SmartMatch] 未匹配,将尝试[P3-JS按行点击]", 2)
|
|
3120
|
+
else:
|
|
3121
|
+
click_uid = _resolve_uid(step, self.parser, self.cache, require_interactive=True)
|
|
3122
|
+
if click_uid:
|
|
3123
|
+
elem = self.parser.elements.get(click_uid)
|
|
3124
|
+
role = elem.role if elem else "?"
|
|
3125
|
+
log(f" 📎 解析到UID: {click_uid} (role={role})", 3)
|
|
3126
|
+
if role in ("radio", "checkbox"):
|
|
3127
|
+
log(f" 📎 ⚠️ 匹配到{role}而非button,尝试排除非button元素...", 2)
|
|
3128
|
+
click_uid = self._find_upload_button_uid(target, None, file_label)
|
|
3129
|
+
if click_uid:
|
|
3130
|
+
log(f" 📎 ✅ 重新匹配到UID: {click_uid}", 3)
|
|
3131
|
+
|
|
3132
|
+
if not click_uid:
|
|
3133
|
+
if file_label:
|
|
3134
|
+
log(f" 📎 🔍 [P3-JS按行] 尝试按file_label='{file_label}'定位上传按钮...", 2)
|
|
3135
|
+
js_click_label = json.dumps(file_label, ensure_ascii=False).strip('"')
|
|
3136
|
+
js_click_fn = (
|
|
3137
|
+
"() => {"
|
|
3138
|
+
" var label = '" + js_click_label.replace("'", "\\'") + "';"
|
|
3139
|
+
" var rows = document.querySelectorAll('tr');"
|
|
3140
|
+
" for (var i = 0; i < rows.length; i++) {"
|
|
3141
|
+
" if (rows[i].textContent.indexOf(label) !== -1) {"
|
|
3142
|
+
" var btn = rows[i].querySelector('button');"
|
|
3143
|
+
" if (!btn) {"
|
|
3144
|
+
" var cells = rows[i].querySelectorAll('td');"
|
|
3145
|
+
" for (var j = 0; j < cells.length; j++) {"
|
|
3146
|
+
" if (cells[j].textContent.indexOf('上传') !== -1) {"
|
|
3147
|
+
" btn = cells[j].querySelector('button');"
|
|
3148
|
+
" if (btn) break;"
|
|
3149
|
+
" }"
|
|
3150
|
+
" }"
|
|
3151
|
+
" }"
|
|
3152
|
+
" if (btn) { btn.click(); return JSON.stringify({js_click:true,label:label}); }"
|
|
3153
|
+
" }"
|
|
3154
|
+
" }"
|
|
3155
|
+
" return JSON.stringify({js_click:false,error:'row_not_found'});"
|
|
3156
|
+
"}"
|
|
3157
|
+
)
|
|
3158
|
+
js_click_result = await self.session.call_tool("evaluate_script", {"function": js_click_fn})
|
|
3159
|
+
js_click_content = self._extract_result_content(js_click_result)
|
|
3160
|
+
log(f" 📎 [P3-JS按行] 结果: {js_click_content[:150] if js_click_content else 'N/A'} (耗时:{(time.time()-step_start_time)*1000:.0f}ms)", 2)
|
|
3161
|
+
|
|
3162
|
+
js_click_info = self._parse_json_from_mcp_response(js_click_content)
|
|
3163
|
+
if isinstance(js_click_info, dict) and js_click_info.get("js_click"):
|
|
3164
|
+
log(f" 📎 ✅ JS成功点击了 '{file_label}' 行的上传按钮", 2)
|
|
3165
|
+
sub_steps.append(f"{sub_step_1}: ✅(JS按行)")
|
|
3166
|
+
click_uid = None
|
|
3167
|
+
else:
|
|
3168
|
+
elapsed = int((time.time() - start_time) * 1000)
|
|
3169
|
+
return StepResult(
|
|
3170
|
+
step_num=step_num, desc=desc, action="el_upload",
|
|
3171
|
+
status=StepStatus.ERROR, mcp_tool="(el_upload)",
|
|
3172
|
+
error=f"Cannot find upload button for target='{target}' (UID stale, JS click also failed)",
|
|
3173
|
+
duration_ms=elapsed,
|
|
3174
|
+
)
|
|
3175
|
+
else:
|
|
3176
|
+
elapsed = int((time.time() - start_time) * 1000)
|
|
3177
|
+
return StepResult(
|
|
3178
|
+
step_num=step_num, desc=desc, action="el_upload",
|
|
3179
|
+
status=StepStatus.ERROR, mcp_tool="(el_upload)",
|
|
3180
|
+
error=f"Cannot find upload button for target='{target}'",
|
|
3181
|
+
duration_ms=elapsed,
|
|
3182
|
+
)
|
|
3183
|
+
|
|
3184
|
+
if click_uid:
|
|
3185
|
+
click_result = await self.session.call_tool("click", {
|
|
3186
|
+
"uid": click_uid,
|
|
3187
|
+
"includeSnapshot": False,
|
|
3188
|
+
})
|
|
3189
|
+
click_content = self._extract_result_content(click_result)
|
|
3190
|
+
log(f" 📎 ✅ [MCP-click] UID={click_uid} 结果: {click_content[:60] if click_content else 'OK'} (耗时:{(time.time()-step_start_time)*1000:.0f}ms)", 2)
|
|
3191
|
+
else:
|
|
3192
|
+
log(f" 📎 ✅ [JS-click] 已通过JS完成按钮点击 (耗时:{(time.time()-step_start_time)*1000:.0f}ms)", 2)
|
|
3193
|
+
sub_steps.append(f"{sub_step_1}: ✅")
|
|
3194
|
+
|
|
3195
|
+
await asyncio.sleep(0.5)
|
|
3196
|
+
|
|
3197
|
+
sub_step_2 = "[2/3] 暴露隐藏的文件输入框"
|
|
3198
|
+
log(f" 📎 ── {sub_step_2} ── | label='{file_label or target}'", 2)
|
|
3199
|
+
|
|
3200
|
+
search_label = json.dumps(file_label or target, ensure_ascii=False).strip('"')
|
|
3201
|
+
expose_ts = str(int(time.time() * 1000))
|
|
3202
|
+
|
|
3203
|
+
expose_fn = (
|
|
3204
|
+
"() => {"
|
|
3205
|
+
" var label = '" + search_label.replace("'", "\\'") + "';"
|
|
3206
|
+
" var ts = '" + expose_ts + "';"
|
|
3207
|
+
" var oldExposed = document.querySelectorAll('[data-el-upload-exposed]');"
|
|
3208
|
+
" for (var oi = 0; oi < oldExposed.length; oi++) {"
|
|
3209
|
+
" oldExposed[oi].removeAttribute('data-el-upload-exposed');"
|
|
3210
|
+
" oldExposed[oi].removeAttribute('aria-label');"
|
|
3211
|
+
" oldExposed[oi].style.cssText = '';"
|
|
3212
|
+
" oldExposed[oi].setAttribute('type','file');"
|
|
3213
|
+
" }"
|
|
3214
|
+
" var rows = document.querySelectorAll('tr');"
|
|
3215
|
+
" var fileInput = null;"
|
|
3216
|
+
" var foundByRow = false;"
|
|
3217
|
+
" for (var i = 0; i < rows.length; i++) {"
|
|
3218
|
+
" if (label && rows[i].textContent.indexOf(label) !== -1) {"
|
|
3219
|
+
" fileInput = rows[i].querySelector('input[type=file]');"
|
|
3220
|
+
" if (!fileInput) {"
|
|
3221
|
+
" fileInput = rows[i].closest('table') ? rows[i].closest('table').querySelector('input[type=file]') : null;"
|
|
3222
|
+
" }"
|
|
3223
|
+
" if (!fileInput) {"
|
|
3224
|
+
" fileInput = rows[i].parentElement ? rows[i].parentElement.querySelector('input[type=file]') : null;"
|
|
3225
|
+
" }"
|
|
3226
|
+
" foundByRow = !!fileInput;"
|
|
3227
|
+
" break;"
|
|
3228
|
+
" }"
|
|
3229
|
+
" }"
|
|
3230
|
+
" if (!fileInput) {"
|
|
3231
|
+
" var allInputs = document.querySelectorAll('input[type=file]');"
|
|
3232
|
+
" if (allInputs.length > 1 && label) {"
|
|
3233
|
+
" for (var ai = allInputs.length - 1; ai >= 0; ai--) {"
|
|
3234
|
+
" var pEl = allInputs[ai].closest('tr') || allInputs[ai].parentElement;"
|
|
3235
|
+
" if (pEl && pEl.textContent.indexOf(label) !== -1) {"
|
|
3236
|
+
" fileInput = allInputs[ai];"
|
|
3237
|
+
" foundByRow = true;"
|
|
3238
|
+
" break;"
|
|
3239
|
+
" }"
|
|
3240
|
+
" }"
|
|
3241
|
+
" }"
|
|
3242
|
+
" if (!fileInput && allInputs.length > 0) {"
|
|
3243
|
+
" fileInput = allInputs[allInputs.length - 1];"
|
|
3244
|
+
" }"
|
|
3245
|
+
" }"
|
|
3246
|
+
" if (!fileInput) return JSON.stringify({error:'no_file_input_found',found:false});"
|
|
3247
|
+
" fileInput.style.cssText = 'position:fixed!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:300px!important;height:40px!important;display:block!important;visibility:visible!important;opacity:1!important;z-index:2147483647!important;border:2px solid red!important;background:yellow!important;font-size:14px!important;padding:5px!important';"
|
|
3248
|
+
" fileInput.setAttribute('data-el-upload-exposed','true');"
|
|
3249
|
+
" fileInput.setAttribute('data-expose-ts',ts);"
|
|
3250
|
+
" fileInput.setAttribute('aria-label','exposed-upload-' + ts);"
|
|
3251
|
+
" fileInput.setAttribute('role','textbox');"
|
|
3252
|
+
" fileInput.setAttribute('tabindex','0');"
|
|
3253
|
+
" fileInput.removeAttribute('disabled');"
|
|
3254
|
+
" fileInput.removeAttribute('hidden');"
|
|
3255
|
+
" if (!fileInput.id) fileInput.id = 'exposed-file-input-' + ts;"
|
|
3256
|
+
" var r = fileInput.getBoundingClientRect();"
|
|
3257
|
+
" return JSON.stringify({found:true,id:fileInput.id,name:fileInput.name||'',exposed:true,ts:ts,w:r.width,h:r.height,foundByRow:foundByRow});"
|
|
3258
|
+
"}"
|
|
3259
|
+
)
|
|
3260
|
+
|
|
3261
|
+
script_result = await self.session.call_tool("evaluate_script", {"function": expose_fn})
|
|
3262
|
+
script_content = self._extract_result_content(script_result)
|
|
3263
|
+
log(f" 📎 [expose] 结果: {script_content[:200] if script_content else 'N/A'} (耗时:{(time.time()-step_start_time)*1000:.0f}ms)", 2)
|
|
3264
|
+
|
|
3265
|
+
expose_info = self._parse_json_from_mcp_response(script_content)
|
|
3266
|
+
|
|
3267
|
+
mcp_error = self._check_result_has_error(script_content) if script_content else True
|
|
3268
|
+
js_success = isinstance(expose_info, dict) and expose_info.get("found")
|
|
3269
|
+
|
|
3270
|
+
if not js_success and mcp_error:
|
|
3271
|
+
elapsed = int((time.time() - start_time) * 1000)
|
|
3272
|
+
return StepResult(
|
|
3273
|
+
step_num=step_num, desc=desc, action="el_upload",
|
|
3274
|
+
status=StepStatus.ERROR, mcp_tool="(el_upload)",
|
|
3275
|
+
error=f"JS execution failed: {script_content[:200] if script_content else 'no response'}",
|
|
3276
|
+
output=script_content,
|
|
3277
|
+
duration_ms=elapsed,
|
|
3278
|
+
)
|
|
3279
|
+
|
|
3280
|
+
if not js_success:
|
|
3281
|
+
log(f" 📎 ⚠️ [2/3] 无法解析JS返回值但MCP未报错,继续尝试上传...", 2)
|
|
3282
|
+
|
|
3283
|
+
sub_steps.append(f"{sub_step_2}: ✅")
|
|
3284
|
+
|
|
3285
|
+
await asyncio.sleep(0.3)
|
|
3286
|
+
|
|
3287
|
+
await self._take_snapshot()
|
|
3288
|
+
|
|
3289
|
+
sub_step_3 = "[3/3] 执行文件上传"
|
|
3290
|
+
log(f" 📎 ── {sub_step_3} ── | path={os.path.basename(file_path)}", 2)
|
|
3291
|
+
|
|
3292
|
+
upload_args = {"filePath": file_path}
|
|
3293
|
+
|
|
3294
|
+
exposed_uid = None
|
|
3295
|
+
best_match = None
|
|
3296
|
+
ts_marker = f"exposed-upload-{expose_ts}"
|
|
3297
|
+
for uid, elem in self.parser.elements.items():
|
|
3298
|
+
elem_text = ((elem.text or "") + " " + (elem.name or "") + " " + (getattr(elem, 'description', '') or "")).lower()
|
|
3299
|
+
if ts_marker.lower() in elem_text:
|
|
3300
|
+
exposed_uid = uid
|
|
3301
|
+
log(f" 📎 精确匹配到时间戳标记的input: uid={uid}", 3)
|
|
3302
|
+
break
|
|
3303
|
+
if "exposed-file-input" in elem_text:
|
|
3304
|
+
best_match = uid
|
|
3305
|
+
if elem.role == "textbox" and ("file" in (elem.name or "").lower() or "upload" in elem_text):
|
|
3306
|
+
if not best_match:
|
|
3307
|
+
best_match = uid
|
|
3308
|
+
|
|
3309
|
+
if not exposed_uid and best_match:
|
|
3310
|
+
exposed_uid = best_match
|
|
3311
|
+
log(f" 📎 使用备选input: uid={best_match}(非精确匹配)", 3)
|
|
3312
|
+
|
|
3313
|
+
if exposed_uid:
|
|
3314
|
+
upload_args["uid"] = exposed_uid
|
|
3315
|
+
log(f" 📎 上传目标UID: {exposed_uid}", 3)
|
|
3316
|
+
|
|
3317
|
+
upload_args["includeSnapshot"] = False
|
|
3318
|
+
|
|
3319
|
+
upload_result = await self.session.call_tool("upload_file", upload_args)
|
|
3320
|
+
upload_content = self._extract_result_content(upload_result)
|
|
3321
|
+
log(f" 📎 ✅ [upload] 结果: {upload_content[:120] if upload_content else 'OK'} (耗时:{(time.time()-step_start_time)*1000:.0f}ms)", 2)
|
|
3322
|
+
sub_steps.append(f"{sub_step_3}: ✅")
|
|
3323
|
+
|
|
3324
|
+
if wait_duration > 0:
|
|
3325
|
+
log(f" 📎 ⏳ 等待 {wait_duration}ms 让页面处理文件...", 2)
|
|
3326
|
+
await asyncio.sleep(wait_duration / 1000.0)
|
|
3327
|
+
|
|
3328
|
+
sub_step_4 = "[4/3] 关闭文件选择窗口"
|
|
3329
|
+
log(f" 📎 {sub_step_4}: 尝试关闭OS级文件对话框", 2)
|
|
3330
|
+
try:
|
|
3331
|
+
import ctypes
|
|
3332
|
+
VK_ESCAPE = 0x1B
|
|
3333
|
+
user32 = ctypes.windll.user32
|
|
3334
|
+
result = user32.keybd_event(VK_ESCAPE, 0, 0, 0)
|
|
3335
|
+
user32.keybd_event(VK_ESCAPE, 0, 2, 0)
|
|
3336
|
+
await asyncio.sleep(0.5)
|
|
3337
|
+
log(f" 📎 {sub_step_4}: Win32 keybd_event Escape 发送成功", 3)
|
|
3338
|
+
sub_steps.append(f"{sub_step_4}: ✅(Win32)")
|
|
3339
|
+
except Exception as win_err:
|
|
3340
|
+
log(f" 📎 {sub_step_4} Win32失败({win_err}),尝试MCP press_key...", 3)
|
|
3341
|
+
try:
|
|
3342
|
+
await self.session.call_tool("press_key", {"key": "Escape"})
|
|
3343
|
+
await asyncio.sleep(0.3)
|
|
3344
|
+
sub_steps.append(f"{sub_step_4}: ✅(MCP)")
|
|
3345
|
+
except Exception as mcp_err:
|
|
3346
|
+
log(f" 📎 ⚠️ {sub_step_4} 所有方式均失败(可忽略)", 3)
|
|
3347
|
+
sub_steps.append(f"{sub_step_4}: ⚠️")
|
|
3348
|
+
|
|
3349
|
+
url_after = getattr(self, 'last_snapshot_url', '') or ''
|
|
3350
|
+
total_elapsed = (time.time() - step_start_time) * 1000
|
|
3351
|
+
log(f" 📎 🌐 执行后URL: {url_after} | 总耗时: {total_elapsed:.0f}ms", 2)
|
|
3352
|
+
|
|
3353
|
+
if url_before and url_after and url_before != url_after:
|
|
3354
|
+
log(f" 📎 ⚠️ ═════ URL变化检测 ═════", 1)
|
|
3355
|
+
log(f" 📎 ⚠️ 变化: {url_before}", 2)
|
|
3356
|
+
log(f" 📎 ⚠️ → {url_after}", 2)
|
|
3357
|
+
if '/apply-list' in url_after or '/list' in url_after:
|
|
3358
|
+
log(f" 📎 🔧 检测到列表页跳转,启动自动恢复...", 1)
|
|
3359
|
+
try:
|
|
3360
|
+
await self._take_snapshot()
|
|
3361
|
+
new_btn_uid = None
|
|
3362
|
+
for uid, elem in self.parser.elements.items():
|
|
3363
|
+
elem_text = (elem.text or "") + (getattr(elem, 'description', '') or "")
|
|
3364
|
+
if elem.role == "button" and "新增" in elem_text:
|
|
3365
|
+
new_btn_uid = uid
|
|
3366
|
+
break
|
|
3367
|
+
if new_btn_uid:
|
|
3368
|
+
log(f" 📎 🔧 找到'新增'按钮 uid={new_btn_uid},点击恢复表单...", 2)
|
|
3369
|
+
await self.session.call_tool("click", {"uid": new_btn_uid, "includeSnapshot": False})
|
|
3370
|
+
await asyncio.sleep(1500 / 1000.0)
|
|
3371
|
+
await self._take_snapshot()
|
|
3372
|
+
restored_url = getattr(self, 'last_snapshot_url', '') or ''
|
|
3373
|
+
if '/apply' in restored_url:
|
|
3374
|
+
log(f" 📎 ✅ 表单页恢复成功: {restored_url} (总耗时:{(time.time()-step_start_time)*1000:.0f}ms)", 1)
|
|
3375
|
+
sub_steps.append("🔧页面自动恢复: ✅")
|
|
3376
|
+
else:
|
|
3377
|
+
log(f" 📎 ⚠️ [el_upload] 恢复后URL: {restored_url}", 2)
|
|
3378
|
+
sub_steps.append("🔧页面自动恢复: ⚠️")
|
|
3379
|
+
else:
|
|
3380
|
+
log(f" 📎 ⚠️ [el_upload] 未找到'新增'按钮,无法自动恢复", 2)
|
|
3381
|
+
sub_steps.append("🔧页面恢复失败: ❌未找到新增按钮")
|
|
3382
|
+
except Exception as restore_err:
|
|
3383
|
+
log(f" 📎 ⚠️ [el_upload] 自动恢复异常: {restore_err}", 2)
|
|
3384
|
+
sub_steps.append(f"🔧页面恢复异常: {restore_err}")
|
|
3385
|
+
|
|
3386
|
+
elapsed = int((time.time() - start_time) * 1000)
|
|
3387
|
+
log(f" 📎 ═════════════════════ el_upload 完成 ═════════════════════", 1)
|
|
3388
|
+
log(f" 📎 📊 结果: ✅ 成功 | 文件: {os.path.basename(file_path)} | 总耗时: {elapsed}ms", 1)
|
|
3389
|
+
|
|
3390
|
+
return StepResult(
|
|
3391
|
+
step_num=step_num, desc=desc, action="el_upload",
|
|
3392
|
+
status=StepStatus.SUCCESS, mcp_tool="(el_upload)",
|
|
3393
|
+
mcp_args={"target": target, "filePath": file_path},
|
|
3394
|
+
output=f"File uploaded successfully: {file_path}\nSub-steps: {' | '.join(sub_steps)}",
|
|
3395
|
+
duration_ms=elapsed,
|
|
3396
|
+
snapshot_before=self.last_snapshot_text,
|
|
3397
|
+
snapshot_after=self.last_snapshot_text,
|
|
3398
|
+
snapshot_path=self.last_snapshot_path,
|
|
3399
|
+
)
|
|
3400
|
+
|
|
3401
|
+
except Exception as e:
|
|
3402
|
+
tb_lines = traceback.format_exc().splitlines()
|
|
3403
|
+
elapsed = int((time.time() - start_time) * 1000)
|
|
3404
|
+
log(f" 📎 [el_upload] ❌ 异常: {e}", 1)
|
|
3405
|
+
for tl in tb_lines[-6:]:
|
|
3406
|
+
log(f" {tl}", 3)
|
|
3407
|
+
|
|
3408
|
+
return StepResult(
|
|
3409
|
+
step_num=step_num, desc=desc, action="el_upload",
|
|
3410
|
+
status=StepStatus.ERROR, mcp_tool="(el_upload)",
|
|
3411
|
+
error=str(e),
|
|
3412
|
+
output=f"Failed at: {' | '.join(sub_steps)}",
|
|
3413
|
+
duration_ms=elapsed,
|
|
3414
|
+
)
|
|
3415
|
+
|
|
3416
|
+
async def _execute_el_date(self, step: Dict, step_num: int,
|
|
3417
|
+
desc: str, start_time: float) -> StepResult:
|
|
3418
|
+
"""Element UI el-date-picker 日期选择器填写(一步完成)
|
|
3419
|
+
|
|
3420
|
+
内部自动执行三步操作:
|
|
3421
|
+
1. click 日期输入框(打开日期选择面板)
|
|
3422
|
+
2. sleep 等待面板渲染
|
|
3423
|
+
3. fill 填入日期值
|
|
3424
|
+
|
|
3425
|
+
YAML 用法:
|
|
3426
|
+
- step: N
|
|
3427
|
+
action: el_date
|
|
3428
|
+
target: 请选择评估基准日 # placeholder 或 label
|
|
3429
|
+
value: '2026-05-10' # 日期字符串
|
|
3430
|
+
wait_after:
|
|
3431
|
+
type: time
|
|
3432
|
+
duration: 500 # 面板打开后等待时间(默认500ms)
|
|
3433
|
+
"""
|
|
3434
|
+
log(f" 📅 [el_date] Element UI 日期选择器开始", 1)
|
|
3435
|
+
|
|
3436
|
+
target = step.get("target", "")
|
|
3437
|
+
value = resolve_env_vars(step.get("value", ""))
|
|
3438
|
+
wait_after = step.get("wait_after", {})
|
|
3439
|
+
panel_wait = int(wait_after.get("duration", 500)) if wait_after else 500
|
|
3440
|
+
|
|
3441
|
+
if not value:
|
|
3442
|
+
elapsed = int((time.time() - start_time) * 1000)
|
|
3443
|
+
return StepResult(
|
|
3444
|
+
step_num=step_num, desc=desc, action="el_date",
|
|
3445
|
+
status=StepStatus.ERROR, mcp_tool="(el_date)",
|
|
3446
|
+
error="Missing required parameter: 'value' (date value to fill)",
|
|
3447
|
+
duration_ms=elapsed,
|
|
3448
|
+
)
|
|
3449
|
+
|
|
3450
|
+
log(f" 📅 [el_date] 目标: '{target}' | 值: '{value}' | 面板等待: {panel_wait}ms", 2)
|
|
3451
|
+
|
|
3452
|
+
sub_steps = []
|
|
3453
|
+
|
|
3454
|
+
try:
|
|
3455
|
+
sub_step_1 = "[1/3] 点击日期输入框"
|
|
3456
|
+
log(f" 📅 {sub_step_1}: target='{target}'", 2)
|
|
3457
|
+
|
|
3458
|
+
click_uid = _resolve_uid(step, self.parser, self.cache, require_interactive=True)
|
|
3459
|
+
if not click_uid:
|
|
3460
|
+
elapsed = int((time.time() - start_time) * 1000)
|
|
3461
|
+
return StepResult(
|
|
3462
|
+
step_num=step_num, desc=desc, action="el_date",
|
|
3463
|
+
status=StepStatus.ERROR, mcp_tool="(el_date)",
|
|
3464
|
+
error=f"Cannot find date input element for target='{target}'",
|
|
3465
|
+
duration_ms=elapsed,
|
|
3466
|
+
)
|
|
3467
|
+
|
|
3468
|
+
click_result = await self.session.call_tool("click", {"uid": click_uid, "includeSnapshot": False})
|
|
3469
|
+
click_content = self._extract_result_content(click_result)
|
|
3470
|
+
log(f" 📅 {sub_step_1} 结果: {click_content[:80] if click_content else 'OK'}", 2)
|
|
3471
|
+
sub_steps.append(f"{sub_step_1}: ✅")
|
|
3472
|
+
|
|
3473
|
+
sub_step_2 = f"[2/3] 等待日期面板渲染 ({panel_wait}ms)"
|
|
3474
|
+
await asyncio.sleep(panel_wait / 1000.0)
|
|
3475
|
+
sub_steps.append(f"{sub_step_2}: ✅")
|
|
3476
|
+
|
|
3477
|
+
await self._take_snapshot()
|
|
3478
|
+
|
|
3479
|
+
sub_step_3 = "[3/3] 填入日期值"
|
|
3480
|
+
log(f" 📅 {sub_step_3}: value='{value}'", 2)
|
|
3481
|
+
|
|
3482
|
+
fill_args = {"value": value}
|
|
3483
|
+
if click_uid:
|
|
3484
|
+
fill_args["uid"] = click_uid
|
|
3485
|
+
|
|
3486
|
+
fill_result = await self.session.call_tool("fill", fill_args)
|
|
3487
|
+
fill_content = self._extract_result_content(fill_result)
|
|
3488
|
+
log(f" 📅 {sub_step_3} 结果: {fill_content[:120] if fill_content else 'OK'}", 2)
|
|
3489
|
+
sub_steps.append(f"{sub_step_3}: ✅")
|
|
3490
|
+
|
|
3491
|
+
elapsed = int((time.time() - start_time) * 1000)
|
|
3492
|
+
log(f" 📅 [el_date] 完成 ({elapsed}ms)", 1)
|
|
3493
|
+
|
|
3494
|
+
return StepResult(
|
|
3495
|
+
step_num=step_num, desc=desc, action="el_date",
|
|
3496
|
+
status=StepStatus.SUCCESS, mcp_tool="(el_date)",
|
|
3497
|
+
mcp_args={"target": target, "value": value},
|
|
3498
|
+
output=f"Date filled successfully: {value}\nSub-steps: {' | '.join(sub_steps)}",
|
|
3499
|
+
duration_ms=elapsed,
|
|
3500
|
+
snapshot_before=self.last_snapshot_text,
|
|
3501
|
+
snapshot_after=self.last_snapshot_text,
|
|
3502
|
+
snapshot_path=self.last_snapshot_path,
|
|
3503
|
+
)
|
|
3504
|
+
|
|
3505
|
+
except Exception as e:
|
|
3506
|
+
tb_lines = traceback.format_exc().splitlines()
|
|
3507
|
+
elapsed = int((time.time() - start_time) * 1000)
|
|
3508
|
+
log(f" 📅 [el_date] ❌ 异常: {e}", 1)
|
|
3509
|
+
for tl in tb_lines[-6:]:
|
|
3510
|
+
log(f" {tl}", 3)
|
|
3511
|
+
|
|
3512
|
+
return StepResult(
|
|
3513
|
+
step_num=step_num, desc=desc, action="el_date",
|
|
3514
|
+
status=StepStatus.ERROR, mcp_tool="(el_date)",
|
|
3515
|
+
error=str(e),
|
|
3516
|
+
output=f"Failed at: {' | '.join(sub_steps)}",
|
|
3517
|
+
duration_ms=elapsed,
|
|
3518
|
+
)
|
|
3519
|
+
|
|
3520
|
+
def _find_upload_button_uid(self, target: str, preferred_uid: Optional[str] = None,
|
|
3521
|
+
file_label: Optional[str] = None) -> Optional[str]:
|
|
3522
|
+
"""上下文感知的上传按钮定位(三级策略)
|
|
3523
|
+
|
|
3524
|
+
策略优先级:
|
|
3525
|
+
P1: UID后缀模糊匹配 (60_370 -> *_370)
|
|
3526
|
+
P2: file_label上下文定位 (找到"核准申请文件"行→取该行button"上传")
|
|
3527
|
+
P3: 智能文本匹配 (排除radio/checkbox,优先button角色)
|
|
3528
|
+
"""
|
|
3529
|
+
log(f" [SmartMatch] target='{target}' preferred_uid={preferred_uid} file_label={file_label}", 3)
|
|
3530
|
+
|
|
3531
|
+
INTERACTIVE_ROLES = {"button", "link", "textbox", "combobox", "menuitem"}
|
|
3532
|
+
TEXT_ONLY_ROLES = {"strong", "statictext", "inline textbox", "heading",
|
|
3533
|
+
"listitem", "paragraph", "label", "image"}
|
|
3534
|
+
|
|
3535
|
+
if preferred_uid:
|
|
3536
|
+
uid_suffix = preferred_uid.split("_")[-1] if "_" in preferred_uid else preferred_uid
|
|
3537
|
+
p1_candidates = []
|
|
3538
|
+
for uid, elem in self.parser.elements.items():
|
|
3539
|
+
if uid.endswith("_" + uid_suffix) or uid == uid_suffix:
|
|
3540
|
+
role = getattr(elem, 'role', '') or ''
|
|
3541
|
+
text = (getattr(elem, 'text', '') or '')[:40]
|
|
3542
|
+
log(f" [SmartMatch-P1] UID后缀匹配: {uid} (role={role} text='{text}')", 3)
|
|
3543
|
+
if role in INTERACTIVE_ROLES:
|
|
3544
|
+
log(f" [SmartMatch-P1] ✅ 找到交互元素: {uid}", 3)
|
|
3545
|
+
return uid
|
|
3546
|
+
elif role not in TEXT_ONLY_ROLES and role not in ("radio", "checkbox", "switch"):
|
|
3547
|
+
p1_candidates.append((uid, role, text))
|
|
3548
|
+
|
|
3549
|
+
if p1_candidates and not file_label:
|
|
3550
|
+
uid, role, text = p1_candidates[0]
|
|
3551
|
+
log(f" [SmartMatch-P1] ⚠️ 使用非标准角色: {uid} (role={role})", 2)
|
|
3552
|
+
return uid
|
|
3553
|
+
|
|
3554
|
+
if p1_candidates:
|
|
3555
|
+
log(f" [SmartMatch-P1] 后缀匹配到非交互元素({len(p1_candidates)}个),降级到P2...", 3)
|
|
3556
|
+
|
|
3557
|
+
if file_label:
|
|
3558
|
+
label_uids = []
|
|
3559
|
+
for uid, elem in self.parser.elements.items():
|
|
3560
|
+
text = (elem.text or "") + (elem.name or "") + (getattr(elem, 'description', '') or "")
|
|
3561
|
+
if file_label.lower() in text.lower():
|
|
3562
|
+
label_uids.append((uid, elem))
|
|
3563
|
+
|
|
3564
|
+
if label_uids:
|
|
3565
|
+
log(f" [SmartMatch-P2] file_label '{file_label}' 匹配到 {len(label_uids)} 个元素:", 3)
|
|
3566
|
+
for lu, le in label_uids:
|
|
3567
|
+
log(f" uid={lu} role={le.role} text={(le.text or '')[:30]}", 3)
|
|
3568
|
+
|
|
3569
|
+
best_btn = self._find_nearest_button(label_uids, target)
|
|
3570
|
+
if best_btn:
|
|
3571
|
+
return best_btn
|
|
3572
|
+
|
|
3573
|
+
candidates = []
|
|
3574
|
+
for uid, elem in self.parser.elements.items():
|
|
3575
|
+
if elem.role in ("radio", "checkbox", "switch"):
|
|
3576
|
+
continue
|
|
3577
|
+
text = (elem.text or "") + (elem.name or "")
|
|
3578
|
+
is_interactive = elem.role in ("button", "link", "textbox", "combobox", "menuitem")
|
|
3579
|
+
if target and target.lower() in text.lower() and is_interactive:
|
|
3580
|
+
score = 0
|
|
3581
|
+
if text.strip().lower() == target.strip().lower():
|
|
3582
|
+
score += 10
|
|
3583
|
+
elif target.lower() in text.lower():
|
|
3584
|
+
score += 5
|
|
3585
|
+
if elem.role == "button":
|
|
3586
|
+
score += 3
|
|
3587
|
+
candidates.append((score, uid, elem))
|
|
3588
|
+
|
|
3589
|
+
candidates.sort(key=lambda x: x[0], reverse=True)
|
|
3590
|
+
if candidates:
|
|
3591
|
+
_, best_uid, best_elem = candidates[0]
|
|
3592
|
+
log(f" [SmartMatch-P3] 最佳文本匹配: uid={best_uid} role={best_elem.role} text={best_elem.text[:30]}", 3)
|
|
3593
|
+
return best_uid
|
|
3594
|
+
|
|
3595
|
+
log(f" [SmartMatch] ❌ 未找到上传按钮", 2)
|
|
3596
|
+
return None
|
|
3597
|
+
|
|
3598
|
+
def _find_nearest_button(self, anchor_elements: List[Tuple], target_text: str) -> Optional[str]:
|
|
3599
|
+
"""在锚点元素附近查找最近的 button"""
|
|
3600
|
+
anchor_uids = {uid for uid, _ in anchor_elements}
|
|
3601
|
+
best_match = None
|
|
3602
|
+
best_distance = float('inf')
|
|
3603
|
+
|
|
3604
|
+
for uid, elem in self.parser.elements.items():
|
|
3605
|
+
if elem.role != "button":
|
|
3606
|
+
continue
|
|
3607
|
+
text = (elem.text or "").strip()
|
|
3608
|
+
if target_text and target_text.lower() not in text.lower():
|
|
3609
|
+
continue
|
|
3610
|
+
|
|
3611
|
+
try:
|
|
3612
|
+
uid_num = int(uid.split("_")[-1]) if "_" in uid else 0
|
|
3613
|
+
except ValueError:
|
|
3614
|
+
continue
|
|
3615
|
+
|
|
3616
|
+
min_anchor_dist = float('inf')
|
|
3617
|
+
for auid, _ in anchor_elements:
|
|
3618
|
+
try:
|
|
3619
|
+
auid_num = int(auid.split("_")[-1]) if "_" in auid else 0
|
|
3620
|
+
except ValueError:
|
|
3621
|
+
continue
|
|
3622
|
+
dist = abs(uid_num - auid_num)
|
|
3623
|
+
if dist < min_anchor_dist:
|
|
3624
|
+
min_anchor_dist = dist
|
|
3625
|
+
|
|
3626
|
+
if min_anchor_dist < best_distance:
|
|
3627
|
+
best_distance = min_anchor_dist
|
|
3628
|
+
best_match = uid
|
|
3629
|
+
|
|
3630
|
+
if best_match:
|
|
3631
|
+
log(f" [NearestButton] 最近button: uid={best_match} 距离anchor={best_distance}", 3)
|
|
3632
|
+
return best_match
|
|
3633
|
+
|
|
2554
3634
|
async def _execute_assert_multiple(self, step: Dict, step_num: int,
|
|
2555
3635
|
desc: str, start_time: float) -> StepResult:
|
|
2556
3636
|
action_type = "assert_multiple"
|
|
@@ -2667,6 +3747,89 @@ class ActionExecutor:
|
|
|
2667
3747
|
content_parts.append(str(item))
|
|
2668
3748
|
return "".join(content_parts)
|
|
2669
3749
|
|
|
3750
|
+
@staticmethod
|
|
3751
|
+
def _parse_json_from_mcp_response(text: str):
|
|
3752
|
+
"""从 MCP 响应文本中提取 JSON 对象(最大容错)
|
|
3753
|
+
|
|
3754
|
+
策略:
|
|
3755
|
+
1. 正则提取 markdown 代码块
|
|
3756
|
+
2. 多轮尝试 json.loads(包括二次解码)
|
|
3757
|
+
3. 正则提取 key:value 作为兜底
|
|
3758
|
+
4. 如果包含 found/exposed 等成功标记,返回 {"found": True}
|
|
3759
|
+
"""
|
|
3760
|
+
if not text or not text.strip():
|
|
3761
|
+
return {}
|
|
3762
|
+
text = text.strip()
|
|
3763
|
+
|
|
3764
|
+
import re
|
|
3765
|
+
|
|
3766
|
+
code_block_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL)
|
|
3767
|
+
if code_block_match:
|
|
3768
|
+
text = code_block_match.group(1).strip()
|
|
3769
|
+
|
|
3770
|
+
text_clean = text.replace('\n', ' ').replace('\r', '')
|
|
3771
|
+
|
|
3772
|
+
def _try_parse(s):
|
|
3773
|
+
s = s.strip()
|
|
3774
|
+
if not s or len(s) < 3:
|
|
3775
|
+
return None
|
|
3776
|
+
for candidate in [s]:
|
|
3777
|
+
try:
|
|
3778
|
+
result = json.loads(candidate)
|
|
3779
|
+
return result
|
|
3780
|
+
except (json.JSONDecodeError, ValueError):
|
|
3781
|
+
pass
|
|
3782
|
+
try:
|
|
3783
|
+
cleaned = candidate.replace('\\n', ' ').replace('\\r', '').replace('\\t', ' ')
|
|
3784
|
+
result = json.loads(cleaned)
|
|
3785
|
+
return result
|
|
3786
|
+
except (json.JSONDecodeError, ValueError):
|
|
3787
|
+
pass
|
|
3788
|
+
return None
|
|
3789
|
+
|
|
3790
|
+
def _try_double_parse(s):
|
|
3791
|
+
first = _try_parse(s)
|
|
3792
|
+
if isinstance(first, dict):
|
|
3793
|
+
return first
|
|
3794
|
+
if isinstance(first, list):
|
|
3795
|
+
return {"_array": first}
|
|
3796
|
+
if isinstance(first, str):
|
|
3797
|
+
second = _try_parse(first)
|
|
3798
|
+
if isinstance(second, dict):
|
|
3799
|
+
return second
|
|
3800
|
+
if isinstance(second, list):
|
|
3801
|
+
return {"_array": second}
|
|
3802
|
+
return first
|
|
3803
|
+
|
|
3804
|
+
result = _try_double_parse(text_clean)
|
|
3805
|
+
if isinstance(result, dict):
|
|
3806
|
+
return result
|
|
3807
|
+
|
|
3808
|
+
start = text_clean.find('{')
|
|
3809
|
+
end = text_clean.rfind('}')
|
|
3810
|
+
if start != -1 and end != -1 and end > start:
|
|
3811
|
+
candidate = text_clean[start:end + 1]
|
|
3812
|
+
result = _try_double_parse(candidate)
|
|
3813
|
+
if isinstance(result, dict):
|
|
3814
|
+
return result
|
|
3815
|
+
|
|
3816
|
+
start = text_clean.find('[')
|
|
3817
|
+
end = text_clean.rfind(']')
|
|
3818
|
+
if start != -1 and end != -1 and end > start:
|
|
3819
|
+
candidate = text_clean[start:end + 1]
|
|
3820
|
+
result = _try_parse(candidate)
|
|
3821
|
+
if isinstance(result, list):
|
|
3822
|
+
return {"_array": result}
|
|
3823
|
+
|
|
3824
|
+
if re.search(r'"found"\s*:\s*true', text_clean) or \
|
|
3825
|
+
re.search(r'"exposed"\s*:\s*true', text_clean) or \
|
|
3826
|
+
re.search(r'found\s*:\s*true', text_clean):
|
|
3827
|
+
log(f" [MCP-Parse] 通过正则检测到成功标记", 3)
|
|
3828
|
+
return {"found": True, "_parse_method": "regex_fallback"}
|
|
3829
|
+
|
|
3830
|
+
log(f" [MCP-Parse] ⚠️ 无法提取JSON对象 (len={len(text_clean)}, has_found={'found' in text_clean.lower()})", 2)
|
|
3831
|
+
return {}
|
|
3832
|
+
|
|
2670
3833
|
@staticmethod
|
|
2671
3834
|
def _check_result_has_error(content: str) -> bool:
|
|
2672
3835
|
"""
|
|
@@ -2830,7 +3993,7 @@ class ReportGenerator:
|
|
|
2830
3993
|
StepStatus.ERROR: "💥", StepStatus.RETRIED: "🔄"}.get(sr.status, "?")
|
|
2831
3994
|
df.write(f"### 步骤{sr.step_num}: {sr.desc} [{icon} {sr.status.value}]\n\n")
|
|
2832
3995
|
df.write(f"```\n动作: {sr.action}\n工具: {sr.mcp_tool}\n")
|
|
2833
|
-
df.write(f"参数: {
|
|
3996
|
+
df.write(f"参数: {_safe_json_dumps(sr.mcp_args, ensure_ascii=False, indent=2)}\n")
|
|
2834
3997
|
if sr.output:
|
|
2835
3998
|
out = sr.output[:600] + "..." if len(sr.output) > 600 else sr.output
|
|
2836
3999
|
df.write(f"结果: {out}\n")
|
|
@@ -2905,6 +4068,9 @@ async def run_single_testcase(yaml_path: str, result_dir: str = None,
|
|
|
2905
4068
|
with open(yaml_path, "r", encoding="utf-8") as f:
|
|
2906
4069
|
testcase = yaml.safe_load(f)
|
|
2907
4070
|
|
|
4071
|
+
base_dir = str(Path(yaml_path).parent)
|
|
4072
|
+
testcase = _resolve_includes(testcase, base_dir)
|
|
4073
|
+
|
|
2908
4074
|
test_id = testcase.get("test_id", "UNKNOWN")
|
|
2909
4075
|
title = testcase.get("title", "未命名")
|
|
2910
4076
|
priority = testcase.get("priority", "P3")
|
|
@@ -3056,7 +4222,14 @@ async def run_single_testcase(yaml_path: str, result_dir: str = None,
|
|
|
3056
4222
|
result.steps.append(sr)
|
|
3057
4223
|
|
|
3058
4224
|
if sr.status in (StepStatus.FAILED, StepStatus.ERROR, StepStatus.FAILED_ASSERT):
|
|
3059
|
-
|
|
4225
|
+
is_critical = (
|
|
4226
|
+
step.get("assertion", {}).get("critical", False) or
|
|
4227
|
+
step.get("critical", False)
|
|
4228
|
+
)
|
|
4229
|
+
if is_critical:
|
|
4230
|
+
log(f"\n🛑 步骤{step_num}关键断言失败,立即终止执行", 1)
|
|
4231
|
+
break
|
|
4232
|
+
elif on_fail_strategy == "stop":
|
|
3060
4233
|
log(f"\n⛔ 步骤{step_num}失败,终止执行 (策略: stop)", 1)
|
|
3061
4234
|
break
|
|
3062
4235
|
elif on_fail_strategy == "retry":
|