pdd-skills 3.1.13 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +1 -1
  3. package/scaffolds/python-fullstack/.github/workflows/ci.yml +1 -1
  4. package/scaffolds/python-fullstack/Dockerfile +1 -1
  5. package/scaffolds/python-fullstack/docker-compose.yml +1 -1
  6. package/scaffolds/python-fullstack/frontend/Dockerfile +1 -1
  7. package/scaffolds/python-fullstack/frontend/nginx.conf +1 -1
  8. package/scaffolds/python-fullstack/frontend/postcss.config.js +1 -1
  9. package/scaffolds/python-fullstack/frontend/src/api/client.ts +1 -1
  10. package/scaffolds/python-fullstack/frontend/src/composables/useResponsive.ts +1 -1
  11. package/scaffolds/python-fullstack/frontend/src/router/index.ts +1 -1
  12. package/scaffolds/python-fullstack/frontend/src/stores/user.ts +1 -1
  13. package/scaffolds/python-fullstack/frontend/src/styles/responsive.css +1 -1
  14. package/scaffolds/python-fullstack/frontend/src/styles/variables.css +1 -1
  15. package/scaffolds/python-fullstack/frontend/src/views/DashboardView.vue +1 -1
  16. package/scaffolds/python-fullstack/frontend/src/views/HomeView.vue +1 -1
  17. package/scaffolds/python-fullstack/frontend/src/views/LoginView.vue +1 -1
  18. package/scaffolds/python-fullstack/frontend/tailwind.config.js +1 -1
  19. package/skills/core/official-doc-writer/README.md +232 -232
  20. package/skills/core/official-doc-writer/fonts/FONTS_LIST.md +44 -44
  21. 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
  22. package/skills/core/pdd-main/evals/evals.json +215 -215
  23. package/skills/entropy/expert-arch-enforcer/SKILL.md +292 -292
  24. package/skills/entropy/expert-auto-refactor/SKILL.md +327 -327
  25. package/skills/entropy/expert-code-quality/SKILL.md +468 -468
  26. package/skills/entropy/expert-entropy-auditor/SKILL.md +276 -276
  27. package/skills/expert/expert-activiti/SKILL.md +497 -497
  28. package/skills/expert/expert-mysql/SKILL.md +832 -832
  29. package/skills/expert/expert-ruoyi/SKILL.md +674 -674
  30. package/skills/expert/testcase-agent/SKILL.md +10 -0
  31. package/skills/expert/testcase-modeler/SKILL.md +45 -2
  32. package/skills/pr/pdd-multi-review/SKILL.md +534 -534
  33. package/skills/pr/pdd-pr-batch/SKILL.md +303 -303
  34. package/skills/pr/pdd-pr-create/SKILL.md +344 -344
  35. package/skills/pr/pdd-pr-merge/SKILL.md +286 -286
  36. package/skills/pr/pdd-pr-review/SKILL.md +217 -217
  37. package/skills/pr/pdd-task-manager/SKILL.md +636 -636
  38. package/skills/pr/pdd-template-engine/SKILL.md +386 -386
  39. package/tests/login_manager.py +5 -30
  40. package/tests/recorder.py +362 -294
  41. package/tests/testcase-ai.py +1184 -11
@@ -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"- 参数: {json.dumps(result.mcp_args, ensure_ascii=False)[:200]}",
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" 📝 参数: {json.dumps(mcp_args, ensure_ascii=False, indent=2)}", 2)
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"参数: {json.dumps(sr.mcp_args, ensure_ascii=False, indent=2)}\n")
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
- if on_fail_strategy == "stop":
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":