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
package/tests/recorder.py CHANGED
@@ -1,338 +1,406 @@
1
1
  """
2
- Testcase Recorder v1.0 - 交互式浏览器操作录制器
2
+ Testcase Recorder v2.0 - 交互式标注录制器
3
3
  ==============================================
4
- testcase-ai.py 中提取的独立录制模块,遵循 SRP 原则。
4
+ "全自动录制"升级为"人机协作标注"模式。
5
5
 
6
- 功能:
7
- 🎬 注入 JS 事件监听器,捕获用户在浏览器中的操作
8
- 📝 将操作事件自动转换为 YAML 测试步骤
9
- 💾 同时生成 .yaml 和 .env 文件
6
+ 核心理念:
7
+ - 录制负责"捕捉鼠标/键盘事件"
8
+ - 人类负责"理解业务语义"
9
+ - 重要节点弹出标注,普通操作自动处理
10
10
 
11
- 用法:
12
- from tests.recorder import run_record_mode
13
- asyncio.run(run_record_mode("output.yaml"))
11
+ v2.0 变更 (重大重构):
12
+ 新增: 智能节点检测算法
13
+ ✨ 新增: 交互式标注界面 (控制台)
14
+ ✨ 新增: 业务语义模板库
15
+ ✨ 改进: 步骤编号连续无跳跃
16
+ ✨ 改进: target 描述更准确
14
17
 
15
- 或通过 CLI:
16
- python tests/testcase-ai.py --record [output_path]
18
+ 用法:
19
+ python tests/testcase-ai.py --record [output.yaml]
20
+ python tests/testcase-ai.py --record-annotate [output.yaml] # 强制标注模式
17
21
  """
18
22
 
19
23
  import asyncio
20
24
  import json
21
25
  import os
26
+ import re
27
+ import shutil
28
+ import subprocess
22
29
  import sys
23
30
  import time
24
- from typing import Any, Dict, List, Optional
31
+ import tempfile
32
+ from typing import Any, Dict, List, Optional, Tuple
25
33
 
26
34
  import yaml
27
-
28
-
29
- if sys.platform == "win32":
30
- import io
31
- sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
32
- sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
35
+ from mcp import ClientSession, StdioServerParameters
36
+ from mcp.client.stdio import stdio_client
33
37
 
34
38
 
35
39
  # ============================================================
36
- # JS 注入脚本:注入到浏览器页面中捕获用户操作
40
+ # 常量定义
37
41
  # ============================================================
38
42
 
39
- RECORDER_JS = """() => {
40
- if (window.__recorder) {
41
- return { ok: false, message: 'Already running', count: window.__recorder.events.length };
42
- }
43
- window.__recorder = { events: [], start: Date.now(), version: '1.0' };
44
-
45
- function getXPath(el) {
46
- const parts = [];
47
- while (el && el.nodeType === 1) {
48
- let idx = 1, sib = el.previousSibling;
49
- while (sib) { if (sib.nodeType === 1) idx++; sib = sib.previousSibling; }
50
- parts.unshift(el.tagName.toLowerCase() + '[' + idx + ']');
51
- el = el.parentNode;
52
- }
53
- return '/' + parts.join('/');
54
- }
55
-
43
+ IMPORTANT_NODE_PATTERNS = {
44
+ "submit": ["登录", "login", "submit", "确定", "确认", "提交", "保存", "申请",
45
+ "下一步", "next", "完成", "finish", "发送", "send"],
46
+ "navigate": ["菜单", "menu", "首页", "home", "返回", "back", "退出", "logout",
47
+ "切换", "switch", "tab"],
48
+ "page_change": [],
49
+ }
50
+
51
+ MEANINGLESS_TARGETS = {"IMG", "SVG", "PATH", "I", "SPAN", "DIV", "A",
52
+ "BUTTON", "INPUT", "FORM", "SECTION", "ARTICLE",
53
+ "HEADER", "FOOTER", "NAV", "MAIN", "ASIDE"}
54
+
55
+ BUSINESS_TEMPLATES = {
56
+ "login": {"desc": "登录系统", "action": "click", "category": "认证"},
57
+ "submit_form": {"desc": "提交表单", "action": "click", "category": "数据操作"},
58
+ "navigate_menu": {"desc": "导航到菜单", "action": "click", "category": "导航"},
59
+ "search_query": {"desc": "执行查询", "action": "click", "category": "查询"},
60
+ "new_tab": {"desc": "打开新标签页", "action": "switch_page", "category": "导航"},
61
+ "fill_field": {"desc": "填写字段", "action": "fill", "category": "数据输入"},
62
+ "select_option": {"desc": "选择选项", "action": "select_option", "category": "选择"},
63
+ }
64
+
65
+
66
+ def _find_chrome_executable() -> str:
67
+ candidates = [
68
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
69
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
70
+ os.path.expanduser(r"~\AppData\Local\Google\Chrome\Application\chrome.exe"),
71
+ ]
72
+ for path in candidates:
73
+ if os.path.exists(path):
74
+ return path
75
+ return shutil.which("chrome") or "chrome"
76
+
77
+
78
+ def _launch_chrome_with_flags(port: int = 9222) -> subprocess.Popen:
79
+ chrome_exe = _find_chrome_executable()
80
+ user_data_dir = tempfile.mkdtemp(prefix="chrome_mcp_")
81
+ args = [
82
+ chrome_exe, f"--remote-debugging-port={port}", "--incognito",
83
+ "--no-first-run", "--no-default-browser-check", "--disable-default-apps",
84
+ "--disable-sync", "--disable-extensions",
85
+ "--disable-component-extensions-with-background-pages", "--disable-popup-blocking",
86
+ "--ignore-certificate-errors", "--ignore-certificate-errors-spki-list",
87
+ "--disable-web-security", "--allow-running-insecure-content",
88
+ "--unsafely-treat-insecure-origin-as-secure", "--window-size=1920,1080",
89
+ f"--user-data-dir={user_data_dir}", "--about:blank",
90
+ ]
91
+ proc = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
92
+ time.sleep(2)
93
+ return proc
94
+
95
+
96
+ def _create_incognito_server_params() -> StdioServerParameters:
97
+ npx_args = ["-y", "chrome-devtools-mcp@latest", "--browser-url=http://127.0.0.1:9222"]
98
+ return StdioServerParameters(name="Chrome DevTools MCP (Connected)", command="npx", args=npx_args, env=None)
99
+
100
+
101
+ SERVER_PARAMS = _create_incognito_server_params()
102
+ _global_chrome_process: Optional[subprocess.Popen] = None
103
+
104
+
105
+ def _extract_text(result) -> str:
106
+ parts = []
107
+ if hasattr(result, 'content') and result.content:
108
+ for item in result.content:
109
+ if hasattr(item, 'text'):
110
+ raw = item.text
111
+ code_block = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', raw, re.DOTALL)
112
+ if code_block: parts.append(code_block.group(1).strip())
113
+ else: parts.append(raw)
114
+ else: parts.append(str(item))
115
+ return "".join(parts)
116
+
117
+
118
+ async def _call(session: ClientSession, tool: str, kwargs: dict = None) -> Any:
119
+ raw = await session.call_tool(tool, kwargs or {})
120
+ text = _extract_text(raw)
121
+ if not text or not text.strip(): return None
122
+ try: return json.loads(text)
123
+ except (json.JSONDecodeError, TypeError): return text
124
+
125
+
126
+ RECORDER_JS = r"""() => {
127
+ if (window.__recorder) { return { ok: false, message: 'Already running', count: window.__recorder.events.length }; }
128
+ window.__recorder = { events: [], start: Date.now(), version: '2.0' };
56
129
  function capture(e, extra) {
57
- const t = e.target;
58
- window.__recorder.events.push({
59
- type: extra.type || e.type,
60
- ts: Date.now(),
61
- tag: t.tagName,
62
- id: t.id || '',
63
- cls: (t.className || '').toString().slice(0, 60),
64
- text: (t.textContent || '').trim().slice(0, 80),
65
- ph: t.placeholder || '',
66
- role: t.getAttribute('role') || '',
67
- val: (t.value || '').slice(0, 100),
68
- href: t.href || '',
69
- name: t.name || '',
70
- xpath: getXPath(t),
71
- url: location.href,
72
- ...extra
73
- });
130
+ const t = e.target; const tag = t.tagName; const role = t.getAttribute('role') || '';
131
+ let desc = ''; const ph = t.placeholder || ''; const name = t.name || ''; const id = t.id || '';
132
+ const val = (t.value || '').slice(0, 100); const href = t.href || ''; const cls = (t.className || '').toString();
133
+ desc = ph || name || id || '';
134
+ if (tag === 'IMG' && !desc) { desc = t.alt || t.title || (t.src || '').split('/').pop().replace(/\.[^.]+$/, '') || ''; }
135
+ if ((tag === 'A' || role === 'link') && !desc && href) { desc = (t.textContent || '').trim().slice(0, 50) || href.slice(0, 50); }
136
+ if ((tag === 'BUTTON' || role === 'button') && !desc) { desc = (t.textContent || '').trim().slice(0, 50) || (t.value || '').slice(0, 50); }
137
+ const isSelect = (tag === 'SELECT' || cls.includes('el-select') || t.closest('.el-select') !== null || role === 'listbox' || role === 'option');
138
+ let selectedText = '';
139
+ if (isSelect && tag === 'SELECT') { selectedText = t.options[t.selectedIndex]?.text || ''; } else if (isSelect) { selectedText = (t.textContent || '').trim().slice(0, 80); }
140
+ window.__recorder.events.push({ type: extra.type || e.type, ts: Date.now(), tag, desc: desc || (t.textContent || '').trim().slice(0, 50), value: val, isSelect, selectedText, url: location.href, ...extra });
74
141
  }
75
-
76
142
  document.addEventListener('click', (e) => capture(e, { type: 'click' }), true);
77
143
  document.addEventListener('input', (e) => capture(e, { type: 'input' }), true);
78
144
  document.addEventListener('change', (e) => capture(e, { type: 'change' }), true);
79
- document.addEventListener('keydown', (e) => {
80
- if (e.key === 'Enter') capture(e, { type: 'enter' });
81
- }, true);
82
-
83
- const origPush = history.pushState;
84
- history.pushState = function() {
85
- window.__recorder.events.push({ type: 'navigate', ts: Date.now(), url: arguments[2] || location.href });
86
- return origPush.apply(this, arguments);
87
- };
88
-
89
- return { ok: true, message: 'Recorder started', pid: performance.now().toString(36) };
145
+ document.addEventListener('keydown', (e) => { if (e.key === 'Enter') capture(e, { type: 'enter' }); }, true);
146
+ return { ok: true, message: 'Recorder v2.0 started', url: location.href };
90
147
  }"""
91
148
 
92
- POLL_JS = """() => {
93
- if (!window.__recorder) return { ok: false };
94
- const evts = window.__recorder.events;
95
- const result = [...evts];
96
- window.__recorder.events = [];
149
+ POLL_JS = r"""() => {
150
+ if (!window.__recorder) return { ok: false, error: 'not_found' };
151
+ const evts = window.__recorder.events; const result = [...evts]; window.__recorder.events = [];
97
152
  return { ok: true, elapsed: Math.round((Date.now() - window.__recorder.start) / 1000), events: result };
98
153
  }"""
99
154
 
100
- STOP_JS = """() => {
155
+ STOP_JS = r"""() => {
101
156
  if (!window.__recorder) return { ok: false, events: [] };
102
- const all = window.__recorder.events;
103
- window.__recorder = null;
157
+ const all = window.__recorder.events; window.__recorder = null;
104
158
  return { ok: true, totalEvents: all.length, events: all };
105
159
  }"""
106
160
 
107
161
 
108
- # ============================================================
109
- # 事件 → 步骤转换器
110
- # ============================================================
111
-
112
- def _event_to_step(evt: Dict, step_num: int) -> Optional[Dict]:
113
- """将录制事件转换为 YAML 步骤
114
-
115
- Args:
116
- evt: 从浏览器捕获的原始事件数据
117
- step_num: 当前步骤序号
162
+ class NodeClassifier:
163
+ @staticmethod
164
+ def classify(evt: Dict) -> Tuple[str, str]:
165
+ etype = evt.get("type", ""); desc = evt.get("desc", "").lower(); tag = evt.get("tag", "").upper()
166
+ if etype == "click":
167
+ if tag in MEANINGLESS_TARGETS and not desc:
168
+ has_info = any([evt.get("imgAlt"), evt.get("imgTitle"), evt.get("selectedText"), evt.get("value")])
169
+ if not has_info: return ("skip", "meaningless_click")
170
+ if etype in ("click", "enter"):
171
+ if any(kw in desc for kw in IMPORTANT_NODE_PATTERNS["submit"]): return ("important", "submit")
172
+ if etype == "click":
173
+ if any(kw in desc for kw in IMPORTANT_NODE_PATTERNS["navigate"]): return ("important", "navigate")
174
+ if evt.get("isSelect") and evt.get("selectedText"): return ("semi_auto", "select")
175
+ if etype in ("input", "change"):
176
+ if evt.get("value"): return ("auto", "fill")
177
+ if etype == "click" and desc: return ("auto", "click")
178
+ return ("skip", "unknown")
179
+
180
+
181
+ class InteractiveAnnotator:
182
+ def __init__(self, force_annotate: bool = False):
183
+ self.force_annotate = force_annotate; self.annotation_count = 0
184
+
185
+ def should_annotate(self, category: str, sub_type: str) -> bool:
186
+ if self.force_annotate: return True
187
+ return category in ("important",)
188
+
189
+ async def annotate(self, step_num: int, step: Dict, category: str, sub_type: str) -> Dict:
190
+ self.annotation_count += 1
191
+ action = step.get("action", ""); desc = step.get("desc", "")
192
+ target = step.get("target", "") or step.get("desc", "")
193
+ print(f"\n{'─' * 50}\n 📝 步骤 #{step_num} 需要标注\n{'─' * 50}")
194
+ print(f" 检测到的操作: [{action}] {desc}\n 目标元素: {target}\n")
195
+ templates = self._get_relevant_templates(sub_type, target)
196
+ print(f" 可选业务模板:")
197
+ for i, (key, tmpl) in enumerate(templates.items(), 1): print(f" [{i}] {tmpl['desc']} ({tmpl['category']})")
198
+ print(f" [c] 自定义描述\n [s] 跳过此步骤\n [d] 使用默认 (保持原样)\n")
199
+ choice = input(f" 请选择 [1-{len(templates)}/c/s/d] (默认d): ").strip().lower() or "d"
200
+ enhanced = dict(step)
201
+ if choice == "s": return None
202
+ elif choice == "c":
203
+ custom_desc = input(f" 请输入业务描述: ").strip()
204
+ if custom_desc: enhanced["desc"] = custom_desc; enhanced["_business_desc"] = custom_desc
205
+ elif choice.isdigit():
206
+ idx = int(choice) - 1; keys = list(templates.keys())
207
+ if 0 <= idx < len(keys):
208
+ selected_key = keys[idx]; tmpl = templates[selected_key]
209
+ enhanced["desc"] = tmpl["desc"]; enhanced["_business_type"] = selected_key; enhanced["_category"] = tmpl["category"]
210
+ if selected_key == "login":
211
+ enhanced["wait_after"] = {"type": "time", "duration": 3000}
212
+ enhanced["assertion"] = {"type": "url_contains", "expected": "", "confidence": "high", "note": "登录后预期URL特征"}
213
+ elif selected_key == "submit_form":
214
+ enhanced["wait_after"] = {"type": "time", "duration": 2000}
215
+ enhanced["assertions"] = [{"type": "toast_visible", "expected": "", "confidence": "high"}, {"type": "network_called", "url_pattern": "/api/*", "method": "POST", "response_code": 200}]
216
+ print()
217
+ return enhanced
218
+
219
+ def _get_relevant_templates(self, sub_type: str, target: str) -> Dict:
220
+ relevant = {}; target_lower = target.lower()
221
+ if any(kw in target_lower for kw in ["登录", "login"]): relevant["login"] = BUSINESS_TEMPLATES["login"]
222
+ if any(kw in target_lower for kw in ["提交", "保存", "申请", "确定", "确认"]): relevant["submit_form"] = BUSINESS_TEMPLATES["submit_form"]
223
+ if any(kw in target_lower for kw in ["查询", "搜索", "search"]): relevant["search_query"] = BUSINESS_TEMPLATES["search_query"]
224
+ if any(kw in target_lower for kw in ["菜单", "menu", "首页", "home"]): relevant["navigate_menu"] = BUSINESS_TEMPLATES["navigate_menu"]
225
+ if sub_type == "fill" and "fill_field" not in relevant: relevant["fill_field"] = BUSINESS_TEMPLATES["fill_field"]
226
+ if sub_type == "select" and "select_option" not in relevant: relevant["select_option"] = BUSINESS_TEMPLATES["select_option"]
227
+ if sub_type == "navigate" and "navigate_menu" not in relevant: relevant["navigate_menu"] = BUSINESS_TEMPLATES["navigate_menu"]
228
+ if not relevant: relevant["generic"] = {"desc": f"{target}", "action": "click", "category": "其他"}
229
+ return relevant
118
230
 
119
- Returns:
120
- YAML 步骤字典,或 None(跳过该事件)
121
- """
122
- etype = evt.get("type", "")
123
231
 
232
+ def _event_to_step(evt: Dict, step_num: int) -> Optional[Dict]:
233
+ etype = evt.get("type", ""); desc = evt.get("desc", "") or ""
234
+ is_select = evt.get("isSelect", False); selected_text = evt.get("selectedText", "")
235
+ value = evt.get("value", ""); tag = evt.get("tag", "").upper()
124
236
  if etype == "click":
125
- text = evt.get("text", "") or ""
126
- ph = evt.get("ph", "") or ""
127
- href = evt.get("href", "")
128
- tag = evt.get("tag", "")
129
-
130
- target = ph or text or href or f"{tag}元素"
131
- if len(target) > 50:
132
- target = target[:47] + "..."
133
-
134
- step = {
135
- "step": step_num,
136
- "desc": f"点击'{target}'",
137
- "action": "click",
138
- "target": target,
139
- }
140
- if href and href.startswith("http"):
141
- step["wait_after"] = {"type": "navigation", "timeout": 8000}
237
+ target = desc or f"{tag}"
238
+ if tag in MEANINGLESS_TARGETS and not desc and not evt.get("imgAlt"): return None
239
+ if len(target) > 60: target = target[:57] + "..."
240
+ step = {"step": step_num, "desc": f"点击'{target}'", "action": "click", "target": target}
142
241
  return step
143
-
144
242
  elif etype in ("input", "change"):
145
- val = evt.get("val", "")
146
- ph = evt.get("ph", "") or ""
147
- target = ph or evt.get("name") or f"{evt.get('tag','')}输入框"
148
- if not val:
149
- return None
150
-
151
- is_number = val.replace(".", "").replace("-", "").isdigit()
152
- action = "fill"
153
-
154
- step = {
155
- "step": step_num,
156
- "desc": f"填写'{target}'为'{val}'",
157
- "action": action,
158
- "target": target,
159
- "value": val,
160
- }
161
- if is_number:
162
- step["desc"] = f"填写数值'{val}'到{target}"
163
- return step
164
-
243
+ if not value: return None
244
+ target = desc or f"字段{step_num}"
245
+ if is_select and selected_text: return {"step": step_num, "desc": f"选择'{selected_text}'", "action": "select_option", "target": target, "option": selected_text}
246
+ if value.replace(".", "").replace("-", "").isdigit():
247
+ return {"step": step_num, "desc": f"填写数值'{value}'到{target}", "action": "fill", "target": target, "value": value}
248
+ return {"step": step_num, "desc": f"填写'{target}'为'{value}'", "action": "fill", "target": target, "value": value}
165
249
  elif etype == "navigate":
166
- return {
167
- "step": step_num,
168
- "desc": f"导航至 '{evt.get('url', '')}'",
169
- "action": "navigate",
170
- "url": evt.get("url", ""),
171
- "wait_after": {"type": "time", "duration": 2000},
172
- }
173
-
174
- elif etype == "enter":
175
- return None
176
-
250
+ return {"step": step_num, "desc": f"页面导航至 '{evt.get('url', '')[:50]}'", "action": "navigate", "url": evt.get("url", ""), "wait_after": {"type": "time", "duration": 2000}}
177
251
  return None
178
252
 
179
253
 
180
- # ============================================================
181
- # 录制模式主流程
182
- # ============================================================
183
-
184
- async def run_record_mode(output_path: str):
185
- """交互式录制模式:监听用户操作 自动生成 YAML + .env
186
-
187
- 流程:
188
- 1. 通过 LoginManager 连接 Chrome DevTools MCP
189
- 2. 向页面注入 RECORDER_JS 事件监听器
190
- 3. 轮询 POLL_JS 获取捕获的事件,实时转换为步骤显示
191
- 4. 用户按 Enter 停止,调用 STOP_JS 收尾
192
- 5. 生成 YAML 用例文件和 .env 环境变量文件
193
-
194
- Args:
195
- output_path: 输出 YAML 文件路径(.env 同目录同名生成)
196
- """
197
- from .login_manager import LoginManager
198
-
199
- print("=" * 60)
200
- print(" Testcase Recorder v1.0")
201
- print(" Interactive Browser Operation Recorder")
202
- print("=" * 60)
203
-
204
- lm = LoginManager()
205
- session = await lm.create_session()
206
- if not session:
207
- print("[ERROR] Cannot connect to Chrome DevTools MCP service")
208
- sys.exit(1)
209
-
210
- print(f"\n[1/4] Browser connected... OK")
211
-
212
- try:
213
- await session.call_tool("evaluate_script", {"function": RECORDER_JS})
214
- print("[2/4] Event listeners injected... OK")
215
- except Exception as e:
216
- print(f"[ERROR] Injection failed: {e}")
217
- sys.exit(1)
218
-
219
- print("""
220
- +--------------------------------------------------+
221
- | You can operate in browser now! |
222
- | |
223
- | - Click buttons, links, inputs |
224
- | - Fill forms, select dropdowns |
225
- | - Navigate to different pages |
226
- | |
227
- | Press Enter to stop recording |
228
- +--------------------------------------------------+
229
- """)
230
-
231
- all_events: List[Dict] = []
232
- step_num = 0
233
- last_url = ""
234
-
235
- import msvcrt
236
- print("\n[3/4] Recording... (Press Enter to stop)\n")
237
-
238
- while True:
239
- await asyncio.sleep(1.5)
240
- try:
241
- raw = await session.call_tool("evaluate_script", {"function": POLL_JS})
242
- data = json.loads(raw[0]["text"]) if isinstance(raw, list) else raw
243
- if data.get("ok") and data.get("events"):
244
- new_evts = data["events"]
245
- for evt in new_evts:
246
- step_num += 1
247
- step = _event_to_step(evt, step_num)
248
- if step:
249
- all_events.append(step)
250
- action_icon = {"click": "[click]", "fill": "[fill]", "navigate": "[nav]"}.get(step["action"], "[act]")
251
- val_info = f" -> '{step.get('value','')}'" if step.get("value") else ""
252
- print(f" [{step_num:02d}] {action_icon} {step['desc']}{val_info}")
253
-
254
- if new_evts:
255
- last_url = new_evts[-1].get("url", last_url)
256
- except Exception:
257
- pass
258
-
259
- if msvcrt.kbhit():
260
- key = msvcrt.getwch()
261
- if key == '\r':
262
- break
263
-
264
- print(f"\n[4/4] Stopped. Captured {len(all_events)} operation steps\n")
265
-
254
+ def _merge_input_events(steps: List[Dict]) -> List[Dict]:
255
+ merged = []; i = 0
256
+ while i < len(steps):
257
+ s = steps[i]
258
+ if s.get("action") in ("input", "change", "fill") and s.get("value"):
259
+ last_val = s["value"]; last_target = s["target"]; j = i + 1
260
+ while j < len(steps):
261
+ nxt = steps[j]
262
+ if (nxt.get("action") in ("input", "change", "fill") and nxt.get("target") == last_target and nxt.get("value")):
263
+ last_val = nxt["value"]; j += 1
264
+ else: break
265
+ merged.append({**s, "value": last_val}); i = j
266
+ else: merged.append(s); i += 1
267
+ return merged
268
+
269
+
270
+ def _enrich_steps(steps: List[Dict]) -> List[Dict]:
271
+ enriched = []
272
+ for s in steps:
273
+ action = s.get("action", ""); step_copy = dict(s)
274
+ if action in ("fill", "click", "select_option"):
275
+ target = s.get("target", "")
276
+ safe_key = re.sub(r'[^a-zA-Z0-9]', '_', target).lower().strip('_') or f"step_{s['step']}"
277
+ step_copy["locator"] = {"uid_cache_key": f"{action}_{safe_key}"}
278
+ if "assertion" not in step_copy and "assertions" not in step_copy:
279
+ if action == "fill":
280
+ target_lower = s.get("target", "").lower()
281
+ if any(kw in target_lower for kw in ("密码", "password", "pass")): step_copy["isSensitive"] = True
282
+ step_copy["assertion"] = {"type": "field_filled", "expected": "已填写", "confidence": "high"}
283
+ elif action == "select_option": step_copy["assertion"] = {"type": "element_text", "expected": s.get("option", ""), "confidence": "high"}
284
+ elif action == "click": step_copy["assertion"] = {"type": "element_visible", "expected": "", "confidence": "medium", "note": "请验证"}
285
+ elif action == "navigate": step_copy["assertion"] = {"type": "text_contains", "expected": "", "confidence": "high", "note": "页面特征"}
286
+ for key in list(step_copy.keys()):
287
+ if key.startswith("_"): del step_copy[key]
288
+ enriched.append(step_copy)
289
+ return enriched
290
+
291
+
292
+ async def run_record_mode(output_path: str, force_annotate: bool = False):
293
+ def log(msg: str): print(msg, flush=True)
294
+ log("=" * 60); log(" Testcase Recorder v2.0"); log(" 模式: 交互式标注录制"); log("=" * 60)
295
+ global _global_chrome_process
296
+ log("\n[0/5] 启动 Chrome Incognito...")
297
+ _global_chrome_process = _launch_chrome_with_flags(port=9222)
298
+ if _global_chrome_process and _global_chrome_process.poll() is None: log(f" Chrome 已启动 (PID: {_global_chrome_process.pid})")
299
+ else: log(" [WARN] Chrome 启动失败")
300
+ annotator = InteractiveAnnotator(force_annotate=force_annotate)
266
301
  try:
267
- raw = await session.call_tool("evaluate_script", {"function": STOP_JS})
268
- final_data = json.loads(raw[0]["text"]) if isinstance(raw, list) else raw
269
- remaining = final_data.get("events", [])
270
- for evt in remaining:
271
- step_num += 1
272
- step = _event_to_step(evt, step_num)
273
- if step:
274
- all_events.append(step)
275
- except Exception:
276
- pass
277
-
278
- if not all_events:
279
- print("[WARN] No operations captured, no files generated.")
280
- return
281
-
282
- yaml_data = {
283
- "test_id": f"REC-{int(time.time())}-recorded",
284
- "title": "Browser Operation Recording (auto-generated)",
285
- "priority": "P1",
286
- "tags": ["recorded", "auto-generated"],
287
- "author": "Testcase Recorder v1.0",
288
- "context_check": {
289
- "login_url": last_url.split("/")[0] + "//" + last_url.split("/")[2] if "://" in last_url else "",
290
- "home_indicator": "",
291
- "credentials": {"username": "${TEST_USER}", "password": "${TEST_PASS}"},
292
- "captcha_required": False,
293
- },
294
- "steps": all_events,
295
- "teardown": [
296
- {"action": "screenshot", "name": "recorded-result-{timestamp}.png", "fullPage": True}
297
- ],
298
- }
299
-
300
- env_vars = set()
301
- for step in all_events:
302
- val = str(step.get("value", ""))
303
- if "${" in val:
304
- env_vars.add(val[2:-1])
305
-
306
- out_dir = os.path.dirname(output_path)
307
- os.makedirs(out_dir, exist_ok=True)
308
-
309
- with open(output_path, 'w', encoding='utf-8') as f:
310
- yaml.dump(yaml_data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
311
- print(f" OK YAML: {output_path}")
312
-
313
- env_path = output_path.rsplit('.', 1)[0] + ".env"
314
- if env_vars:
315
- with open(env_path, 'w', encoding='utf-8') as f:
316
- f.write("# Recorded test case environment variables (auto-generated)\n")
317
- for var in sorted(env_vars):
318
- f.write(f"{var}=<please fill>\n")
319
- print(f" OK ENV: {env_path} ({len(env_vars)} vars pending)")
320
- else:
321
- with open(env_path, 'w', encoding='utf-8') as f:
322
- f.write("# Recorded test case environment variables (auto-generated)\nTEST_USER=yuanye\nTEST_PASS=yuanye\n")
323
- print(f" OK ENV: {env_path}")
324
-
325
- print(f"\n{'-' * 50}")
326
- print(f"Recording complete!")
327
- print(f" Case ID : {yaml_data['test_id']}")
328
- print(f" Steps : {len(all_events)}")
329
- print(f" Output:")
330
- print(f" - {output_path}")
331
- print(f" - {env_path}")
332
- print(f"\nNext steps:")
333
- print(f" 1. Edit .env file to fill real test data")
334
- print(f" 2. Review and optimize YAML step descriptions and assertions")
335
- print(f" 3. Run: python tests/testcase-ai.py {output_path}")
336
-
337
-
338
- __all__ = ["run_record_mode", "_event_to_step", "RECORDER_JS", "POLL_JS", "STOP_JS"]
302
+ async with stdio_client(SERVER_PARAMS) as (_read, _write):
303
+ async with ClientSession(_read, _write) as session:
304
+ await session.initialize(); log("\n[1/5] Browser connected... OK")
305
+ current_url = await _prepare_page(session, log)
306
+ if not current_url: return
307
+ inject_data = await _call(session, "evaluate_script", {"function": RECORDER_JS})
308
+ if isinstance(inject_data, dict) and inject_data.get("ok"): log("[2/5] Event listeners injected... OK")
309
+ log("\n+--------------------------------------------------+\n| 🎬 录制中... |\n| 📌 重要操作时会弹出标注提示 |\n| ⌨️ Ctrl+C 结束录制 |\n+--------------------------------------------------\n")
310
+ all_events: List[Dict] = []; step_num = 0; poll_count = 0; consecutive_errors = 0; MAX_ERRORS = 5
311
+ log("[3/5] Recording...\n")
312
+ try:
313
+ while True:
314
+ await asyncio.sleep(1.5); poll_count += 1
315
+ data = await _call(session, "evaluate_script", {"function": POLL_JS})
316
+ if data is None:
317
+ if poll_count % 10 == 0: log(f" [poll#{poll_count}] (无响应)")
318
+ consecutive_errors += 1
319
+ if consecutive_errors >= MAX_ERRORS: log("\n [!] 连接可能丢失"); break
320
+ continue
321
+ consecutive_errors = 0
322
+ if not isinstance(data, dict) or not data.get("ok"):
323
+ if poll_count % 20 == 0: log(f" [poll#{poll_count}] Error: {data.get('error','?') if isinstance(data,dict) else '?'}")
324
+ continue
325
+ evts = data.get("events", [])
326
+ if evts:
327
+ for evt in evts:
328
+ raw_step = _event_to_step(evt, step_num + 1)
329
+ if not raw_step: continue
330
+ category, sub_type = NodeClassifier.classify(evt)
331
+ if category == "skip": log(f" [-] 跳过: {raw_step.get('desc', '?')[:40]}"); continue
332
+ step_num += 1; raw_step["step"] = step_num
333
+ if annotator.should_annotate(category, sub_type):
334
+ annotated = await annotator.annotate(step_num, raw_step, category, sub_type)
335
+ if annotated is None: log(f" [{step_num:02d}] ⏭️ 用户跳过"); step_num -= 1; continue
336
+ final_step = annotated; log(f" [{step_num:02d}] ✅ 已标注: {final_step.get('desc', '?')}")
337
+ else:
338
+ final_step = raw_step
339
+ icon = {"click": "[click]", "fill": "[fill]", "select": "[sel]", "nav": "[nav]"}.get(final_step["action"], "[act]")
340
+ val = f" = '{final_step.get('value', '')}'" if final_step.get("value") else ""
341
+ opt = f" → {final_step.get('option', '')}" if final_step.get("option") else ""
342
+ log(f" [{step_num:02d}] {icon} {final_step['desc']}{val}{opt}")
343
+ all_events.append(final_step)
344
+ elif poll_count % 15 == 0: log(f" [poll#{poll_count}] ... ({data.get('elapsed','?')}s)")
345
+ except (KeyboardInterrupt, asyncio.CancelledError): log("\n\n[4/5] Recording stopped by user")
346
+ final_data = await _call(session, "evaluate_script", {"function": STOP_JS})
347
+ if isinstance(final_data, dict) and final_data.get("events"):
348
+ for evt in final_data["events"]:
349
+ step_num += 1; step = _event_to_step(evt, step_num)
350
+ if step: step["step"] = step_num; all_events.append(step)
351
+ log(f"\n Captured: {len(all_events)} steps")
352
+ if not all_events: log("[WARN] No operations captured."); return
353
+ merged = _merge_input_events(all_events); final_steps = _enrich_steps(merged)
354
+ nav_step = {"step": 1, "desc": "打开目标页面", "action": "navigate", "url": current_url, "wait_after": {"type": "time", "duration": 2000}, "assertion": {"type": "text_contains", "expected": "", "confidence": "high", "note": "页面标题或关键文本"}}
355
+ for s in final_steps: s["step"] = s["step"] + 1
356
+ final_steps.insert(0, nav_step)
357
+ log(f" Final: {len(final_steps)} steps\n")
358
+ base_url = ""; parts = current_url.split("/") if "://" in current_url else []
359
+ if len(parts) >= 3: base_url = parts[0] + "//" + parts[2]
360
+ yaml_data = {"test_id": f"REC-{int(time.time())}-annotated", "title": f"交互式标注测试用例 - {os.path.basename(output_path)}", "priority": "P1", "tags": ["recorded", "annotated", "v2.0"], "author": "Testcase Recorder v2.0 (Interactive)", "context_check": {"login_url": base_url, "home_indicator": "", "credentials": {"username": "${TEST_USER}", "password": "${TEST_PASS}"}, "captcha_required": False}, "steps": final_steps, "teardown": [{"action": "screenshot", "name": "result-{timestamp}.png", "fullPage": True}], "metadata": {"created_by": "testcase-recorder v2.0", "annotation_count": annotator.annotation_count, "total_steps": len(final_steps), "note": "使用交互式标注模式生成,重要节点已由人工确认"}}
361
+ out_dir = os.path.dirname(output_path); os.makedirs(out_dir, exist_ok=True)
362
+ with open(output_path, 'w', encoding='utf-8') as f: yaml.dump(yaml_data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
363
+ log(f" OK YAML: {output_path}")
364
+ env_path = output_path.rsplit('.', 1)[0] + ".env"; extracted_env: Dict[str, str] = {}
365
+ for s in final_steps:
366
+ val = s.get("value", ""); target = s.get("target", "").lower()
367
+ if not val or "${" in val: continue
368
+ if any(kw in target for kw in ("用户名", "username")): extracted_env["TEST_USER"] = val
369
+ elif any(kw in target for kw in ("密码", "password")): extracted_env["TEST_PASS"] = val
370
+ with open(env_path, 'w', encoding='utf-8') as f:
371
+ f.write(f"# Generated by Testcase Recorder v2.0 at {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n")
372
+ for k, v in sorted(extracted_env.items()): f.write(f"{k}={v}\n")
373
+ log(f" OK ENV: {env_path}")
374
+ log(f"\n{'=' * 50}\nRecording complete!\n Steps : {len(final_steps)}\n Annotated: {annotator.annotation_count} nodes\n Output :\n - {output_path}\n - {env_path}")
375
+ except Exception as e: log(f"\n[FATAL] {type(e).__name__}: {e}"); import traceback; traceback.print_exc()
376
+ finally:
377
+ if _global_chrome_process and _global_chrome_process.poll() is None:
378
+ log("\n[清理] 关闭 Chrome..."); _global_chrome_process.terminate()
379
+
380
+
381
+ async def _prepare_page(session: ClientSession, log) -> Optional[str]:
382
+ pages_raw = await session.call_tool("list_pages", {})
383
+ pages_text = _extract_text(pages_raw)
384
+ pages_data = json.loads(pages_text) if isinstance(pages_text, str) else (pages_text if isinstance(pages_text, list) else None)
385
+ current_url = "about:blank"
386
+ if isinstance(pages_data, list) and len(pages_data) > 0:
387
+ for p in pages_data:
388
+ if isinstance(p, dict):
389
+ url = p.get("url", ""); title = p.get("title", "") or url; pid = p.get("pageId", 0)
390
+ is_blank = "about:blank" in url; log(f" Tab[{pid}] {title}{' ← 当前' if is_blank else ''}")
391
+ if not is_blank and url.startswith("http"): current_url = url
392
+ if current_url == "about:blank":
393
+ target_url = input("\n [!] 当前空白页,请输入目标 URL\n URL> ").strip()
394
+ if target_url:
395
+ if not target_url.startswith(("http://", "https://")): target_url = "http://" + target_url
396
+ log(f"\n 导航至: {target_url}")
397
+ await _call(session, "evaluate_script", {"function": f"() => {{ location.href = '{target_url}'; return location.href; }}"})
398
+ log(" 等待加载..."); await asyncio.sleep(3)
399
+ check = await _call(session, "evaluate_script", {"function": "() => location.href"})
400
+ if isinstance(check, str): current_url = check
401
+ verify = await _call(session, "evaluate_script", {"function": "() => location.href"})
402
+ if isinstance(verify, str): current_url = verify
403
+ log(f"\n[1/5] Target: {current_url}"); return current_url
404
+
405
+
406
+ __all__ = ["run_record_mode"]