pdd-skills 3.1.11 → 3.1.13
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/lib/init.js +3 -1
- package/package.json +1 -1
- package/skills/expert/testcase-modeler/SKILL.md +107 -0
- package/tests/__init__.py +0 -0
- package/tests/recorder.py +338 -0
- package/tests/testcase-ai.py +23 -1
package/lib/init.js
CHANGED
|
@@ -35,6 +35,8 @@ const AI_TEST_DIRS = [
|
|
|
35
35
|
const AI_TEST_SOURCE_FILES = [
|
|
36
36
|
'tests/testcase-ai.py',
|
|
37
37
|
'tests/login_manager.py',
|
|
38
|
+
'tests/recorder.py',
|
|
39
|
+
'tests/__init__.py',
|
|
38
40
|
];
|
|
39
41
|
|
|
40
42
|
const AI_TEST_EXAMPLE_FILES = [
|
|
@@ -498,7 +500,7 @@ export async function initProject(targetPath = '.', options = {}) {
|
|
|
498
500
|
console.log(chalk.gray(` [OK] ${file.path}`));
|
|
499
501
|
}
|
|
500
502
|
|
|
501
|
-
// Copy AI Test Framework source files (testcase-ai.py, login_manager.py)
|
|
503
|
+
// Copy AI Test Framework source files (testcase-ai.py, login_manager.py, recorder.py, __init__.py)
|
|
502
504
|
const srcRoot = path.resolve(__dirname, '..');
|
|
503
505
|
console.log(chalk.blue('\n>> Copying AI Test source files...\n'));
|
|
504
506
|
for (const rel of AI_TEST_SOURCE_FILES) {
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"pdd-skills","version":"3.1.
|
|
1
|
+
{"name":"pdd-skills","version":"3.1.13","type":"module","description":"PDD Skills - PRD驱动开发框架 | 41+技能: 核心(12)/专家(8)/熵减(4)/OpenSpec(10)/PR管理(7) | AI原生开发工作流 | Bug模式库(14) | PRD规则(30) | 4级门控引擎","main":"index.js","bin":{"pdd":"./bin/pdd.js","pdd-skills":"./bin/pdd.js"},"files":["bin/","lib/","skills/","templates/","scaffolds/","scripts/","config/","hooks/","docs/","index.js","tests/testcase-ai.py","tests/login_manager.py","tests/recorder.py","tests/__init__.py","testcases/examples/"],"scripts":{"start":"node bin/pdd.js","list":"node bin/pdd.js list","lint":"node bin/pdd.js linter --type code prd sql activiti","generate":"node bin/pdd.js generate","verify":"node bin/pdd.js verify","report":"node bin/pdd.js report","config":"node bin/pdd.js config --list","api":"node bin/pdd.js api","api:dev":"node bin/pdd.js api -p 3000 --cors","init":"node bin/pdd.js init","update":"node bin/pdd.js update","cso":"node bin/pdd.js cso","eval":"node bin/pdd.js eval","token":"node bin/pdd.js token","i18n":"node bin/pdd.js i18n"},"keywords":["pdd","prd-driven-development","ai","claude","skills","tdd","code-generation","linter","api-server","cli"],"author":"PDD Team","license":"MIT","engines":{"node":">=18.0.0"},"dependencies":{"chalk":"^5.3.0","commander":"^12.0.0","fs-extra":"^11.2.0","yaml":"^2.3.0"}}
|
|
@@ -449,6 +449,113 @@ Modeler 应了解 testcase-agent 的底层能力边界:
|
|
|
449
449
|
|
|
450
450
|
---
|
|
451
451
|
|
|
452
|
+
## 🎬 交互式录制模式(🆕 v2.0 新增)
|
|
453
|
+
|
|
454
|
+
除了**自然语言描述 → YAML** 的建模方式,testcase-modeler 还支持 **浏览器操作录制 → YAML** 模式。
|
|
455
|
+
|
|
456
|
+
### 使用方式
|
|
457
|
+
|
|
458
|
+
```bash
|
|
459
|
+
# 方式1:录制到默认路径
|
|
460
|
+
python tests/testcase-ai.py --record
|
|
461
|
+
|
|
462
|
+
# 方式2:录制到指定路径
|
|
463
|
+
python tests/testcase-ai.py --record testcases/01-系统状况/my-test.yaml
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### 录制流程
|
|
467
|
+
|
|
468
|
+
```
|
|
469
|
+
┌─────────────────────────────────────────────────────┐
|
|
470
|
+
│ 1. 启动录制器 │
|
|
471
|
+
│ $ python testcase-ai.py --record │
|
|
472
|
+
│ [1/4] 连接浏览器... ✅ │
|
|
473
|
+
│ [2/4] 注入事件监听器... ✅ │
|
|
474
|
+
└──────────────────┬──────────────────────────────────┘
|
|
475
|
+
▼
|
|
476
|
+
┌─────────────────────────────────────────────────────┐
|
|
477
|
+
│ 2. 用户在浏览器中操作 │
|
|
478
|
+
│ │
|
|
479
|
+
│ ╔════════════════════════════════════╗ │
|
|
480
|
+
│ ║ 🖥️ 现在可以在浏览器中操作了! ║ │
|
|
481
|
+
│ ║ ║ │
|
|
482
|
+
│ ║ • 点击按钮、链接、输入框等 ║ │
|
|
483
|
+
│ ║ • 填写表单、选择下拉框 ║ │
|
|
484
|
+
│ ║ • 导航到不同页面 ║ │
|
|
485
|
+
│ ║ ║ │
|
|
486
|
+
│ ║ 操作完成后,按 Enter 停止录制 ║ │
|
|
487
|
+
│ ╚════════════════════════════════════╝ │
|
|
488
|
+
│ │
|
|
489
|
+
│ 终端实时显示: │
|
|
490
|
+
│ [01] 👆 点击'用户名输入框' │
|
|
491
|
+
│ [02] ✏️ 填写'用户名输入框'为'test_user' │
|
|
492
|
+
│ [03] ✏️ 填写'密码输入框'为'******' │
|
|
493
|
+
│ [04] 👆 点击'登录按钮' │
|
|
494
|
+
│ [05] 🌐 导航至 'http://...' │
|
|
495
|
+
│ [06] 👆 点击'资产处置及评估系统' │
|
|
496
|
+
│ ... │
|
|
497
|
+
└──────────────────┬──────────────────────────────────┘
|
|
498
|
+
▼
|
|
499
|
+
┌─────────────────────────────────────────────────────┐
|
|
500
|
+
│ 3. 按 Enter 停止录制 │
|
|
501
|
+
│ [4/4] 停止录制,共捕获 6 个操作步骤 │
|
|
502
|
+
│ │
|
|
503
|
+
│ 自动生成: │
|
|
504
|
+
│ ✅ testcases/recorded/testcase.yaml │
|
|
505
|
+
│ ✅ testcases/recorded/testcase.env │
|
|
506
|
+
│ │
|
|
507
|
+
│ 💡 下一步:编辑 .env → 补充断言 → 执行测试 │
|
|
508
|
+
└─────────────────────────────────────────────────────┘
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
### 录制能力
|
|
512
|
+
|
|
513
|
+
| 可录制的事件 | 转换结果 | 说明 |
|
|
514
|
+
|------------|---------|------|
|
|
515
|
+
| **点击** (click) | `action: click` + `target` | 按钮、链接、菜单项 |
|
|
516
|
+
| **输入** (input/change) | `action: fill` + `target` + `value` | 文本框、数字框 |
|
|
517
|
+
| **导航** (pushState) | `action: navigate` + `url` | 页面跳转 |
|
|
518
|
+
| **Enter 键** | 忽略(通常伴随 submit) | 表单提交 |
|
|
519
|
+
|
|
520
|
+
### 技术原理
|
|
521
|
+
|
|
522
|
+
```
|
|
523
|
+
1. evaluate_script 注入 JS 事件监听器到页面
|
|
524
|
+
→ document.addEventListener('click', ...)
|
|
525
|
+
→ document.addEventListener('input', ...)
|
|
526
|
+
→ history.pushState 劫持(捕获导航)
|
|
527
|
+
|
|
528
|
+
2. 每 1.5 秒轮询 POLL_JS 提取新事件
|
|
529
|
+
→ 实时显示操作步骤到终端
|
|
530
|
+
|
|
531
|
+
3. 用户按 Enter 触发 STOP_JS
|
|
532
|
+
→ 收集所有事件 → _event_to_step() 转换
|
|
533
|
+
→ yaml.dump() 写入文件 + .env 配对生成
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
### 适用场景
|
|
537
|
+
|
|
538
|
+
| 场景 | 推荐方式 |
|
|
539
|
+
|------|---------|
|
|
540
|
+
| 已知业务流程,快速生成用例 | 自然语言描述(Modeler) |
|
|
541
|
+
| **未知流程,边操作边记录** | **🎬 录制模式(推荐)** |
|
|
542
|
+
| 复杂多步操作,难以文字描述 | **🎬 录制模式(推荐)** |
|
|
543
|
+
| 首次接触的新功能探索性测试 | **🎬 录制模式(推荐)** |
|
|
544
|
+
| 需要精确元素定位信息 | **🎬 录制模式(推荐)** |
|
|
545
|
+
|
|
546
|
+
### 录制后优化建议
|
|
547
|
+
|
|
548
|
+
录制生成的 YAML 是**初稿**,建议进行以下优化:
|
|
549
|
+
|
|
550
|
+
1. **补充断言**:每个关键步骤添加 `assertion`
|
|
551
|
+
2. **优化 target 文本**:录制的 target 可能不够语义化,改为业务语言
|
|
552
|
+
3. **添加 wait_after**:导航和提交后添加等待策略
|
|
553
|
+
4. **环境变量化**:将硬编码值替换为 `${VAR}` 引用
|
|
554
|
+
5. **添加 context_check**:补全前置状态感知配置
|
|
555
|
+
6. **拆分长用例**:超过 15 步考虑拆分为多个用例
|
|
556
|
+
|
|
557
|
+
---
|
|
558
|
+
|
|
452
559
|
## 🔧 工作流程
|
|
453
560
|
|
|
454
561
|
### 完整交互流程
|
|
File without changes
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Testcase Recorder v1.0 - 交互式浏览器操作录制器
|
|
3
|
+
==============================================
|
|
4
|
+
从 testcase-ai.py 中提取的独立录制模块,遵循 SRP 原则。
|
|
5
|
+
|
|
6
|
+
功能:
|
|
7
|
+
🎬 注入 JS 事件监听器,捕获用户在浏览器中的操作
|
|
8
|
+
📝 将操作事件自动转换为 YAML 测试步骤
|
|
9
|
+
💾 同时生成 .yaml 和 .env 文件
|
|
10
|
+
|
|
11
|
+
用法:
|
|
12
|
+
from tests.recorder import run_record_mode
|
|
13
|
+
asyncio.run(run_record_mode("output.yaml"))
|
|
14
|
+
|
|
15
|
+
或通过 CLI:
|
|
16
|
+
python tests/testcase-ai.py --record [output_path]
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from typing import Any, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
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")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ============================================================
|
|
36
|
+
# JS 注入脚本:注入到浏览器页面中捕获用户操作
|
|
37
|
+
# ============================================================
|
|
38
|
+
|
|
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
|
+
|
|
56
|
+
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
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
document.addEventListener('click', (e) => capture(e, { type: 'click' }), true);
|
|
77
|
+
document.addEventListener('input', (e) => capture(e, { type: 'input' }), true);
|
|
78
|
+
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) };
|
|
90
|
+
}"""
|
|
91
|
+
|
|
92
|
+
POLL_JS = """() => {
|
|
93
|
+
if (!window.__recorder) return { ok: false };
|
|
94
|
+
const evts = window.__recorder.events;
|
|
95
|
+
const result = [...evts];
|
|
96
|
+
window.__recorder.events = [];
|
|
97
|
+
return { ok: true, elapsed: Math.round((Date.now() - window.__recorder.start) / 1000), events: result };
|
|
98
|
+
}"""
|
|
99
|
+
|
|
100
|
+
STOP_JS = """() => {
|
|
101
|
+
if (!window.__recorder) return { ok: false, events: [] };
|
|
102
|
+
const all = window.__recorder.events;
|
|
103
|
+
window.__recorder = null;
|
|
104
|
+
return { ok: true, totalEvents: all.length, events: all };
|
|
105
|
+
}"""
|
|
106
|
+
|
|
107
|
+
|
|
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: 当前步骤序号
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
YAML 步骤字典,或 None(跳过该事件)
|
|
121
|
+
"""
|
|
122
|
+
etype = evt.get("type", "")
|
|
123
|
+
|
|
124
|
+
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}
|
|
142
|
+
return step
|
|
143
|
+
|
|
144
|
+
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
|
+
|
|
165
|
+
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
|
+
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
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
|
+
|
|
266
|
+
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"]
|
package/tests/testcase-ai.py
CHANGED
|
@@ -3178,7 +3178,7 @@ async def run_all(targets: List[Dict], env_overrides: Dict[str, str] = None,
|
|
|
3178
3178
|
|
|
3179
3179
|
def print_usage():
|
|
3180
3180
|
print("=" * 70)
|
|
3181
|
-
print(" AI Test Framework v2.1 - 通用 AI 测试框架 (
|
|
3181
|
+
print(" AI Test Framework v2.1 - 通用 AI 测试框架 (含录制模式)")
|
|
3182
3182
|
print("=" * 70)
|
|
3183
3183
|
print()
|
|
3184
3184
|
print("用法:")
|
|
@@ -3188,6 +3188,10 @@ def print_usage():
|
|
|
3188
3188
|
print(f" python testcase-ai.py <yaml路径> 运行单个文件")
|
|
3189
3189
|
print(f" python testcase-ai.py --continue 失败后继续执行")
|
|
3190
3190
|
print()
|
|
3191
|
+
print("🎬 录制模式 (交互式操作录制):")
|
|
3192
|
+
print(f" python testcase-ai.py --record 录制到 testcases/recorded/")
|
|
3193
|
+
print(f" python testcase-ai.py --record <路径> 录制到指定路径")
|
|
3194
|
+
print()
|
|
3191
3195
|
print("思维链选项:")
|
|
3192
3196
|
print(f" python testcase-ai.py --think <yaml> 启用 LLM 思维链")
|
|
3193
3197
|
print(f" python testcase-ai.py --think-deep <yaml> 深度思维模式")
|
|
@@ -3221,6 +3225,11 @@ def print_usage():
|
|
|
3221
3225
|
print(f" └─ {fi['filename']}")
|
|
3222
3226
|
|
|
3223
3227
|
|
|
3228
|
+
# ============================================================
|
|
3229
|
+
# Recorder Mode - delegated to tests/recorder.py (SRP extraction)
|
|
3230
|
+
# ============================================================
|
|
3231
|
+
|
|
3232
|
+
|
|
3224
3233
|
if __name__ == "__main__":
|
|
3225
3234
|
register_builtin_actions()
|
|
3226
3235
|
register_builtin_assertions()
|
|
@@ -3231,6 +3240,19 @@ if __name__ == "__main__":
|
|
|
3231
3240
|
print_usage()
|
|
3232
3241
|
sys.exit(0)
|
|
3233
3242
|
|
|
3243
|
+
# ===== 录制模式(优先于其他模式)=====
|
|
3244
|
+
if "--record" in args:
|
|
3245
|
+
args.remove("--record")
|
|
3246
|
+
output = args[0] if args else "testcases/recorded/testcase.yaml"
|
|
3247
|
+
if not output.endswith((".yaml", ".yml")):
|
|
3248
|
+
output = os.path.join(output, "testcase.yaml") if not output.endswith(".yaml") else output
|
|
3249
|
+
import importlib.util
|
|
3250
|
+
_spec = importlib.util.spec_from_file_location("recorder", os.path.join(os.path.dirname(__file__), "recorder.py"))
|
|
3251
|
+
_recorder = importlib.util.module_from_spec(_spec)
|
|
3252
|
+
_spec.loader.exec_module(_recorder)
|
|
3253
|
+
asyncio.run(_recorder.run_record_mode(output))
|
|
3254
|
+
sys.exit(0)
|
|
3255
|
+
|
|
3234
3256
|
targets = []
|
|
3235
3257
|
global_config = {}
|
|
3236
3258
|
|