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/recorder.py
CHANGED
|
@@ -1,338 +1,406 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Testcase Recorder
|
|
2
|
+
Testcase Recorder v2.0 - 交互式标注录制器
|
|
3
3
|
==============================================
|
|
4
|
-
从
|
|
4
|
+
从"全自动录制"升级为"人机协作标注"模式。
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
核心理念:
|
|
7
|
+
- 录制负责"捕捉鼠标/键盘事件"
|
|
8
|
+
- 人类负责"理解业务语义"
|
|
9
|
+
- 重要节点弹出标注,普通操作自动处理
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
v2.0 变更 (重大重构):
|
|
12
|
+
✨ 新增: 智能节点检测算法
|
|
13
|
+
✨ 新增: 交互式标注界面 (控制台)
|
|
14
|
+
✨ 新增: 业务语义模板库
|
|
15
|
+
✨ 改进: 步骤编号连续无跳跃
|
|
16
|
+
✨ 改进: target 描述更准确
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
40
|
+
# 常量定义
|
|
37
41
|
# ============================================================
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if
|
|
149
|
-
return
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
print(
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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"]
|