@szc-ft/mcp-szcd-client 0.28.2 → 0.29.0
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/agents/szcd-component-expert.qoder.md +1 -0
- package/opencode-extension/skills/local-browser-test/SKILL.md +122 -19
- package/opencode-extension/skills/local-browser-test/lib/ant-adapter.js +128 -109
- package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +155 -3
- package/opencode-extension/skills/local-browser-test/package.json +1 -0
- package/opencode-extension/skills/szcd-upload-zip/SKILL.md +122 -0
- package/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +122 -19
- package/qwen-extension/skills/local-browser-test/lib/ant-adapter.js +128 -109
- package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +155 -3
- package/qwen-extension/skills/local-browser-test/package.json +1 -0
- package/qwen-extension/skills/szcd-upload-zip/SKILL.md +122 -0
- package/standard-skill/local-browser-test/SKILL.md +122 -19
- package/standard-skill/local-browser-test/lib/ant-adapter.js +128 -109
- package/standard-skill/local-browser-test/lib/browser-engine.js +155 -3
- package/standard-skill/local-browser-test/package.json +1 -0
- package/standard-skill/szcd-upload-zip/SKILL.md +122 -0
|
@@ -24,6 +24,7 @@ export class BrowserEngine {
|
|
|
24
24
|
this._activeFrame = null; // 当前活跃 iframe 上下文(微前端场景)
|
|
25
25
|
this._adapter = null; // 懒加载 AntAdapter
|
|
26
26
|
this._lastActWasDropdown = false; // 用于 act 入口自动 closeAllDropdowns
|
|
27
|
+
this._wujieContext = null; // wujie 微前端模式 { type, frameCount, wujieApp? }
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -72,6 +73,133 @@ export class BrowserEngine {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/**
|
|
77
|
+
* 检测 wujie 微前端模式(WebComponent / iframe)
|
|
78
|
+
* @returns {{ type: 'webcomponent'|'iframe'|'none', frameCount: number, wujieApp?: object }}
|
|
79
|
+
*/
|
|
80
|
+
async _detectWujieMode() {
|
|
81
|
+
const frames = this.page.frames();
|
|
82
|
+
const frameCount = frames.length;
|
|
83
|
+
const mainFrame = this.page.mainFrame();
|
|
84
|
+
|
|
85
|
+
// 检查主文档中是否有 <wujie-app> 自定义元素(WebComponent 模式标志)
|
|
86
|
+
const wujieInfo = await mainFrame.evaluate(() => {
|
|
87
|
+
const app = document.querySelector('wujie-app');
|
|
88
|
+
if (!app) return null;
|
|
89
|
+
const sr = app.shadowRoot;
|
|
90
|
+
return {
|
|
91
|
+
tagName: app.tagName,
|
|
92
|
+
className: app.className,
|
|
93
|
+
hasShadowRoot: !!sr,
|
|
94
|
+
shadowChildren: sr?.children?.length || 0,
|
|
95
|
+
shadowBodyText: sr?.querySelector('body')?.innerText?.substring(0, 80) || ''
|
|
96
|
+
};
|
|
97
|
+
}).catch(() => null);
|
|
98
|
+
|
|
99
|
+
if (wujieInfo) {
|
|
100
|
+
const ctx = { type: 'webcomponent', frameCount, wujieApp: wujieInfo };
|
|
101
|
+
process.stderr.write(`[wujie] WebComponent 模式: <${wujieInfo.tagName} class="${wujieInfo.className}"> shadowRoot=${wujieInfo.hasShadowRoot} children=${wujieInfo.shadowChildren} frames=${frameCount}\n`);
|
|
102
|
+
if (wujieInfo.shadowBodyText) {
|
|
103
|
+
process.stderr.write(`[wujie] shadow body preview: "${wujieInfo.shadowBodyText}"\n`);
|
|
104
|
+
}
|
|
105
|
+
// 列出所有 frame 供排障
|
|
106
|
+
for (let i = 0; i < frames.length; i++) {
|
|
107
|
+
process.stderr.write(`[wujie] frame[${i}]: ${frames[i].url().substring(0, 100)}\n`);
|
|
108
|
+
}
|
|
109
|
+
return ctx;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (frameCount > 1) {
|
|
113
|
+
process.stderr.write(`[wujie] iframe 模式: ${frameCount} frames\n`);
|
|
114
|
+
for (let i = 0; i < frames.length; i++) {
|
|
115
|
+
process.stderr.write(`[wujie] frame[${i}]: ${frames[i].url().substring(0, 100)}\n`);
|
|
116
|
+
}
|
|
117
|
+
return { type: 'iframe', frameCount };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
process.stderr.write(`[wujie] 未检测到微前端 (frames=${frameCount})\n`);
|
|
121
|
+
return { type: 'none', frameCount };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 安全获取页面列表(优先同步 targets() 避免 pages() 挂死 + 泄露 WebSocket)
|
|
126
|
+
*/
|
|
127
|
+
async _safeGetPages() {
|
|
128
|
+
// 1. 优先使用同步 targets(),不会挂死也不会泄露未完成的 promise
|
|
129
|
+
try {
|
|
130
|
+
const targets = this.browser.targets();
|
|
131
|
+
const pageTargets = targets.filter((t) => t.type() === "page");
|
|
132
|
+
if (pageTargets.length > 0) {
|
|
133
|
+
const pages = [];
|
|
134
|
+
for (const t of pageTargets) {
|
|
135
|
+
try {
|
|
136
|
+
const p = await Promise.race([
|
|
137
|
+
t.page(),
|
|
138
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("page() timeout")), 3000)),
|
|
139
|
+
]);
|
|
140
|
+
if (p) pages.push(p);
|
|
141
|
+
} catch {
|
|
142
|
+
// 跳过无法获取的 target
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (pages.length > 0) return pages;
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// targets() 失败
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. 回退:CDP Target.getTargets
|
|
152
|
+
try {
|
|
153
|
+
const client = await this.browser.target().createCDPSession();
|
|
154
|
+
const { targetInfos } = await client.send("Target.getTargets");
|
|
155
|
+
const pageInfos = targetInfos.filter((t) => t.type === "page");
|
|
156
|
+
const pages = [];
|
|
157
|
+
for (const info of pageInfos) {
|
|
158
|
+
try {
|
|
159
|
+
const target = await Promise.race([
|
|
160
|
+
this.browser.waitForTarget((t) => t.url() === info.url, { timeout: 2000 }),
|
|
161
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("waitForTarget timeout")), 3000)),
|
|
162
|
+
]);
|
|
163
|
+
if (target) {
|
|
164
|
+
const p = await Promise.race([
|
|
165
|
+
target.page(),
|
|
166
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("page() timeout")), 3000)),
|
|
167
|
+
]);
|
|
168
|
+
if (p) pages.push(p);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// skip
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (pages.length > 0) return pages;
|
|
175
|
+
} catch {
|
|
176
|
+
// CDP 回退也失败
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 3. 最后手段:只能尝试 pages()(带严格超时,用完立即 disconnect/reconnect 清理)
|
|
180
|
+
try {
|
|
181
|
+
const pages = await Promise.race([
|
|
182
|
+
this.browser.pages(),
|
|
183
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("pages() timeout")), 5000)),
|
|
184
|
+
]);
|
|
185
|
+
if (pages && pages.length > 0) return pages;
|
|
186
|
+
} catch {
|
|
187
|
+
// pages() 超时;注意此时 browser.pages() 的 WebSocket 可能泄露
|
|
188
|
+
// 清理:断开后重新连接
|
|
189
|
+
try {
|
|
190
|
+
const wsUrl = this.browser.wsEndpoint();
|
|
191
|
+
const puppeteer = await loadPuppeteer();
|
|
192
|
+
await this.browser.disconnect();
|
|
193
|
+
this.browser = await puppeteer.connect({ browserWSEndpoint: wsUrl, defaultViewport: null });
|
|
194
|
+
} catch {
|
|
195
|
+
// reconnect 失败,继续用当前 browser
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 4. 最后手段:newPage()
|
|
200
|
+
return [await this.browser.newPage()];
|
|
201
|
+
}
|
|
202
|
+
|
|
75
203
|
/**
|
|
76
204
|
* 模式 A:连接到用户已有的 Chrome
|
|
77
205
|
*/
|
|
@@ -82,7 +210,7 @@ export class BrowserEngine {
|
|
|
82
210
|
this.ownedBrowser = false;
|
|
83
211
|
|
|
84
212
|
// 获取页面:优先使用指定 URL 匹配的标签页
|
|
85
|
-
const pages = await this.
|
|
213
|
+
const pages = await this._safeGetPages();
|
|
86
214
|
if (options.pageUrl) {
|
|
87
215
|
this.page = pages.find((p) => p.url().includes(options.pageUrl))
|
|
88
216
|
|| await this.browser.newPage();
|
|
@@ -96,6 +224,9 @@ export class BrowserEngine {
|
|
|
96
224
|
await this.page.setViewport(options.viewport);
|
|
97
225
|
}
|
|
98
226
|
|
|
227
|
+
// 微前端模式检测
|
|
228
|
+
this._wujieContext = await this._detectWujieMode();
|
|
229
|
+
|
|
99
230
|
return {
|
|
100
231
|
mode: "connect",
|
|
101
232
|
cdpUrl,
|
|
@@ -223,6 +354,7 @@ export class BrowserEngine {
|
|
|
223
354
|
frameUrl: targetFrame.frame.url(),
|
|
224
355
|
matchedBy: targetFrame.matchedBy,
|
|
225
356
|
},
|
|
357
|
+
wujieContext: this._wujieContext,
|
|
226
358
|
frames,
|
|
227
359
|
tree,
|
|
228
360
|
interactiveElements,
|
|
@@ -247,7 +379,10 @@ export class BrowserEngine {
|
|
|
247
379
|
|
|
248
380
|
const targetFrame = await this._resolveObserveFrame(options);
|
|
249
381
|
if (options.menuPath) {
|
|
250
|
-
|
|
382
|
+
// 菜单在 main frame 外壳中渲染,不随子应用切换
|
|
383
|
+
const menuFrame = this.page.mainFrame();
|
|
384
|
+
const menuTarget = { frame: menuFrame, frameId: "main", matchedBy: "main (menu shell)" };
|
|
385
|
+
return this._actMenuPath(menuTarget, options);
|
|
251
386
|
}
|
|
252
387
|
|
|
253
388
|
const index = options.index !== undefined ? Number.parseInt(options.index, 10) : undefined;
|
|
@@ -944,6 +1079,15 @@ export class BrowserEngine {
|
|
|
944
1079
|
}
|
|
945
1080
|
|
|
946
1081
|
async _resolveObserveFrame(options = {}) {
|
|
1082
|
+
// testCase setup 已预定位 iframe,且调用方未显式传 frame 参数 → 直接复用
|
|
1083
|
+
if (this._activeFrame && options.frameIndex === undefined && !options.frameUrlContains && !options.frameContentContains) {
|
|
1084
|
+
const frames = this.page.frames();
|
|
1085
|
+
const idx = frames.indexOf(this._activeFrame);
|
|
1086
|
+
if (idx >= 0) {
|
|
1087
|
+
return { frame: this._activeFrame, frameId: idx === 0 ? "main" : `frame-${idx}`, matchedBy: "activeFrame (from testCase setup)" };
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
947
1091
|
const frames = this.page.frames();
|
|
948
1092
|
const mainFrame = this.page.mainFrame();
|
|
949
1093
|
|
|
@@ -973,6 +1117,14 @@ export class BrowserEngine {
|
|
|
973
1117
|
}
|
|
974
1118
|
}
|
|
975
1119
|
|
|
1120
|
+
// 无显式 frame 参数时:WebComponent 模式默认用 sandbox iframe(含子应用实际 DOM)
|
|
1121
|
+
if (this._wujieContext?.type === 'webcomponent' && frames.length > 1) {
|
|
1122
|
+
const sandboxIdx = frames.findIndex((f) => f !== mainFrame && !f.url().startsWith('about:'));
|
|
1123
|
+
if (sandboxIdx >= 0) {
|
|
1124
|
+
return { frame: frames[sandboxIdx], frameId: `frame-${sandboxIdx}`, matchedBy: 'webcomponent sandbox iframe' };
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
976
1128
|
const mainIndex = frames.indexOf(mainFrame);
|
|
977
1129
|
return { frame: mainFrame, frameId: mainIndex <= 0 ? "main" : `frame-${mainIndex}`, matchedBy: "main" };
|
|
978
1130
|
}
|
|
@@ -1693,7 +1845,7 @@ export class BrowserEngine {
|
|
|
1693
1845
|
* 查找已打开的标签页(connect 模式,端口可变场景)
|
|
1694
1846
|
*/
|
|
1695
1847
|
async _findPage(step) {
|
|
1696
|
-
const pages = await this.
|
|
1848
|
+
const pages = await this._safeGetPages();
|
|
1697
1849
|
let matched = null;
|
|
1698
1850
|
let matchReason = "";
|
|
1699
1851
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type": "module"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: szcd-upload-zip
|
|
3
|
+
description: |
|
|
4
|
+
本地 ZIP 文件上传与解压工具。将用户本地的 zip 压缩包通过 `upload-zip` REST 端点上传到 szcd-mcp-server,
|
|
5
|
+
自动解压到服务器的 `uploads/<zip包名>/` 目录,并返回解压后的文件列表。
|
|
6
|
+
上传后解压的文件可通过文件下载接口按路径获取或供其他流程使用。
|
|
7
|
+
compatibility:
|
|
8
|
+
tools:
|
|
9
|
+
- run_command
|
|
10
|
+
runtime:
|
|
11
|
+
- node >= 18(读取配置用,仅 `cat ~/.szcd-mcp-config.json` 场景)
|
|
12
|
+
- curl(文件上传所需)
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# ZIP 上传与解压工具(szcd-upload-zip)
|
|
16
|
+
|
|
17
|
+
## 概述
|
|
18
|
+
|
|
19
|
+
当 AI 需要将用户本地的 ZIP 压缩包上传到 szcd-mcp-server 时,使用此技能。
|
|
20
|
+
上传后服务器自动解压,开发者或 AI 可通过下载 API 访问解压后的文件。
|
|
21
|
+
|
|
22
|
+
## 适用场景
|
|
23
|
+
|
|
24
|
+
- 用户有一个本地 zip 包需要上传到服务器存储
|
|
25
|
+
- zipped 源码/资源包需要上传到服务器供后续流程使用
|
|
26
|
+
- 需要在服务器端解压 zip 并取用其中的文件
|
|
27
|
+
|
|
28
|
+
## 工作流
|
|
29
|
+
|
|
30
|
+
### 第 0 步:探测运行时
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
node --version 2>/dev/null && echo "NODE_OK"; \
|
|
34
|
+
curl --version 2>/dev/null | head -1 && echo "CURL_OK"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 第 1 步:读取配置获取服务器地址和 API Key
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
cat ~/.szcd-mcp-config.json
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
配置示例:
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"MCP_SERVER_URL": "http://localhost:3456",
|
|
47
|
+
"MCP_TIMEOUT": 30000,
|
|
48
|
+
"MCP_API_KEY": "your-api-key"
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- 若 `MCP_API_KEY` 为空字符串,则上传时不带 `X-API-Key` header
|
|
53
|
+
- 若 `MCP_SERVER_URL` 未配置,默认使用 `http://localhost:3456`
|
|
54
|
+
|
|
55
|
+
### 第 2 步:上传 ZIP 文件
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# 从 ~/.szcd-mcp-config.json 取 URL 和 API Key
|
|
59
|
+
MCP_URL=$(node -e "const c=require('fs').readFileSync(require('os').homedir()+'/.szcd-mcp-config.json','utf8');const j=JSON.parse(c);console.log(j.MCP_SERVER_URL||'http://localhost:3456')")
|
|
60
|
+
MCP_KEY=$(node -e "const c=require('fs').readFileSync(require('os').homedir()+'/.szcd-mcp-config.json','utf8');const j=JSON.parse(c);console.log(j.MCP_API_KEY||'')")
|
|
61
|
+
|
|
62
|
+
# 上传 zip 文件
|
|
63
|
+
if [ -n "$MCP_KEY" ]; then
|
|
64
|
+
curl -s -X POST "$MCP_URL/upload-zip" -H "X-API-Key: $MCP_KEY" -F "file=@<zip文件路径>"
|
|
65
|
+
else
|
|
66
|
+
curl -s -X POST "$MCP_URL/upload-zip" -F "file=@<zip文件路径>"
|
|
67
|
+
fi
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
其中 `<zip文件路径>` 替换为实际 zip 文件的绝对路径。
|
|
71
|
+
|
|
72
|
+
**响应示例:**
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"ok": true,
|
|
76
|
+
"dir_name": "my-package",
|
|
77
|
+
"extract_dir": "/scity/zengzhijie/mcp/uploads/my-package",
|
|
78
|
+
"file_count": 5,
|
|
79
|
+
"files": [
|
|
80
|
+
{ "path": "index.html", "size": 2048 },
|
|
81
|
+
{ "path": "js/app.js", "size": 15360 },
|
|
82
|
+
{ "path": "css/style.css", "size": 4096 },
|
|
83
|
+
{ "path": "images/logo.png", "size": 819200 },
|
|
84
|
+
{ "path": "data/config.json", "size": 512 }
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 第 3 步:查看解压后的文件
|
|
90
|
+
|
|
91
|
+
上传成功后,可通过下载 API 查看解压后的文件或取单个文件内容:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# 列出解压目录
|
|
95
|
+
curl -s "$MCP_URL/api/download/list?dir=/scity/zengzhijie/mcp/uploads/my-package" \
|
|
96
|
+
${MCP_KEY:+-H "X-API-Key: $MCP_KEY"}
|
|
97
|
+
|
|
98
|
+
# 下载单个文件
|
|
99
|
+
curl -s "$MCP_URL/api/download?path=/scity/zengzhijie/mcp/uploads/my-package/data/config.json" \
|
|
100
|
+
${MCP_KEY:+-H "X-API-Key: $MCP_KEY"}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## 限制说明
|
|
104
|
+
|
|
105
|
+
- 仅支持真正的 ZIP 格式(magic number `PK\x03\x04` 校验),改扩展名无用
|
|
106
|
+
- 单文件大小上限 100MB
|
|
107
|
+
- 同名 zip 包名会覆盖服务器上已有的目录
|
|
108
|
+
- 解压后文件持久存储,无自动过期
|
|
109
|
+
- 上传路径始终为 `/upload-zip`(不含 `/api/` 前缀)
|
|
110
|
+
|
|
111
|
+
## 完整命令示例(一步完成)
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# 读取配置
|
|
115
|
+
MCP_URL=$(node -e "try{const c=require('fs').readFileSync(require('os').homedir()+'/.szcd-mcp-config.json','utf8');const j=JSON.parse(c);console.log(j.MCP_SERVER_URL||'http://localhost:3456')}catch{console.log('http://localhost:3456')}")
|
|
116
|
+
MCP_KEY=$(node -e "try{const c=require('fs').readFileSync(require('os').homedir()+'/.szcd-mcp-config.json','utf8');const j=JSON.parse(c);console.log(j.MCP_API_KEY||'')}catch{console.log('')}")
|
|
117
|
+
|
|
118
|
+
# 上传
|
|
119
|
+
curl -s -X POST "$MCP_URL/upload-zip" \
|
|
120
|
+
${MCP_KEY:+-H "X-API-Key: $MCP_KEY"} \
|
|
121
|
+
-F "file=@/path/to/archive.zip" | python3 -m json.tool
|
|
122
|
+
```
|
package/package.json
CHANGED
|
@@ -215,13 +215,18 @@ AI 直接构造测试计划 JSON:
|
|
|
215
215
|
| click | 点击元素(兼容) | `selector`, `waitForNavigation` | `{ clicked, tagName }` |
|
|
216
216
|
| type | 输入文本(兼容) | `selector`, `text`, `clear` | `{ typed, value }` |
|
|
217
217
|
| waitFor | 等待条件 | `selector` 或 `url`, `timeout` | `{ matched, waited }` |
|
|
218
|
-
| evaluate | 执行 JS | `expression` | `{ result }` |
|
|
218
|
+
| evaluate | 执行 JS(浏览器端) | `expression`, `expectResult?`, `expectResultContains?`, `failOnFalsy?` | `{ result, passed, reason }` |
|
|
219
219
|
| checkElement | 元素断言(兼容) | `selector`, `expect: {visible, minCount, maxCount, hasText}` | `{ passed, checks, elementCount }` |
|
|
220
220
|
| findPage | 查找已打开的标签页 | `urlIncludes` 或 `urlRegex` 或 `titleIncludes` | `{ found, url, title, totalPages }` |
|
|
221
|
-
| findFrame | 查找微前端 iframe | `
|
|
221
|
+
| findFrame | 查找微前端 iframe | `frameSelector`(CSS选择器) 或 `frameUrlContains` 或 `frameContentContains` 或 `frameIndex` | `{ found, frameUrl, matchedBy, totalFrames }` |
|
|
222
222
|
| loginWait | 等待 SSO 登录完成 | `loginSelector`, `timeout`, `interval` | `{ loggedIn, elapsed, currentUrl }` |
|
|
223
223
|
| aiAssert | 语义断言(截图+审查) | `assertion`, `filename` | `{ screenshotPath, assertion, needsVisualReview }` |
|
|
224
224
|
| runTask | 高层任务 runner(批量动态菜单扫描) | `task: "menu-api-scan"`, `menuApiPattern`, `menuLimit`, `menuPathPrefix[]`, `menuFilter[]`, `drawerTriggerSelector`, `useNativeHover`, `apiCaptureOptions`, `continueOnFail` | `{ task, discovered, attempted, byOutcome, results[] }` |
|
|
225
|
+
| **apiCall** | **API 直调(绕过 UI)** | `method`, `url`, `body?`, `headers?` | `{ status, data, passed }` |
|
|
226
|
+
| **switchPage** | **多标签页切换** | `urlContains?`/`titleIncludes?`/`pageIndex?` | `{ passed, url, title }` |
|
|
227
|
+
| **cdpScript** | **调用内置/已注册脚本** | `script`, `params?` | `{ acted, passed, ...result }` |
|
|
228
|
+
| **rawScript** | **Node.js 端执行任意 async** | `code`, `params?` | `{ acted, passed, ...result }` |
|
|
229
|
+
| **registerCdpScript** | **注册可复用脚本** | `name`, `code` | `{ registered, passed }` |
|
|
225
230
|
|
|
226
231
|
### 场景选择策略
|
|
227
232
|
|
|
@@ -246,14 +251,33 @@ AI 直接构造测试计划 JSON:
|
|
|
246
251
|
|
|
247
252
|
### 构造步骤的指导原则
|
|
248
253
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
-
|
|
255
|
-
|
|
256
|
-
|
|
254
|
+
**⚠️ 强制流程:构造 test-case JSON 前必须先执行 DOM 预检查**
|
|
255
|
+
|
|
256
|
+
1. **observe 检查目标页面 DOM 结构**
|
|
257
|
+
- 确认 frame 结构(主页面 + 子应用 iframe)
|
|
258
|
+
- 确认表格/表单/按钮的 selector
|
|
259
|
+
- 确认 setup.findFrame 的匹配方式(frameSelector/frameUrlContains)
|
|
260
|
+
|
|
261
|
+
2. **evaluate 检查关键元素文字**
|
|
262
|
+
- 按钮文字(如“提 交”中间有空格,不能用“提交”匹配)
|
|
263
|
+
- Select 选项文字(如“AI知识主题库”可能与 UI 显示不一致)
|
|
264
|
+
- 表单字段 label(如“数据资源名称”vs“数据集名称”)
|
|
265
|
+
- 表格列头(确认列数和列名)
|
|
266
|
+
|
|
267
|
+
3. **根据 DOM 检查结果构造 test-case JSON**
|
|
268
|
+
- selector 从 observe 获取(不猜测)
|
|
269
|
+
- 选项文字从 evaluate 获取(不猜测)
|
|
270
|
+
- 按钮文字从 evaluate 获取(注意空格)
|
|
271
|
+
- 无项目代码时,DOM 检查替代代码读取
|
|
272
|
+
|
|
273
|
+
4. **act 文字匹配**
|
|
274
|
+
- `text=新建` → 模糊匹配(includes),可能误匹配“新建目录”
|
|
275
|
+
- `text==新建` → 精确匹配(===),只匹配文字恰好为“新建”的元素
|
|
276
|
+
- 按钮文字可能有空格(如“提 交”),evaluate 检查后再选择匹配方式
|
|
277
|
+
|
|
278
|
+
5. **API 路径从代码中的接口调用读取,或从 API 文档获取**
|
|
279
|
+
6. **checkElement 的 expect 从代码逻辑推断**
|
|
280
|
+
7. **compare 的 regions 从设计稿分析结果或组件布局推断**
|
|
257
281
|
|
|
258
282
|
## 微前端测试指南(实战经验)
|
|
259
283
|
|
|
@@ -692,11 +716,15 @@ cd ~/.szcd-mcp/deps && npm install --registry=https://registry.npmmirror.com --@
|
|
|
692
716
|
| `formFill` | 批量填充 + 可选 `validateAfter` | `fields[]` |
|
|
693
717
|
| `closeAllDropdowns` | 关闭所有下拉浮层 | - |
|
|
694
718
|
|
|
695
|
-
|
|
696
|
-
- Ant Select 必须 `mousedown`
|
|
697
|
-
- 选项异步渲染,内置
|
|
698
|
-
-
|
|
699
|
-
-
|
|
719
|
+
**关键约束(6/22 反馈后更新)**:
|
|
720
|
+
- Ant Select 必须 `mousedown` + `click` 触发(click 触发 React onChange)
|
|
721
|
+
- 选项异步渲染,内置 poll 轮询(timeout 默认 3s,不限制 1.5s)
|
|
722
|
+
- `closeAllDropdowns` 去掉 `body >` 限制(适配微前端浮层)+ 增加 300ms sleep
|
|
723
|
+
- 选项点击后 mousedown + mouseup + **click**(三步组合)
|
|
724
|
+
- VERIFY 全局兜底:marker 丢失时全局搜索 `.ant-select-selection-item`
|
|
725
|
+
- VERIFY 检查 `title` + `innerText` + `textContent`(三重兜底)
|
|
726
|
+
- `popupResolved` 变量提升到 for 循环外(避免作用域 bug)
|
|
727
|
+
- 浮层选择器去掉 `body >` 限制(`.ant-select-dropdown` 而非 `body > .ant-select-dropdown`)
|
|
700
728
|
|
|
701
729
|
```json
|
|
702
730
|
{ "type": "antSelect", "field": "数据集名称", "option": "测试集A" }
|
|
@@ -732,6 +760,75 @@ node local-browser-executor.js --action test-case --test-case-file /tmp/case.jso
|
|
|
732
760
|
}
|
|
733
761
|
```
|
|
734
762
|
|
|
763
|
+
## 脚本执行能力分层
|
|
764
|
+
|
|
765
|
+
| 层级 | 步骤类型 | 执行环境 | 能力 | 适用场景 |
|
|
766
|
+
|------|---------|---------|------|---------|
|
|
767
|
+
| 1 | evaluate | 浏览器端 | DOM 查询/操作 | 简单检查、点击元素 |
|
|
768
|
+
| 2 | rawScript | Node.js 端 | 完整 puppeteer API | 跨 frame、等待、多标签页 |
|
|
769
|
+
| 3 | cdpScript | Node.js 端 | 调用已注册脚本 | 复用通用交互模式 |
|
|
770
|
+
| 4 | 内置 cdpScript | Node.js 端 | 预定义通用 UI 交互 | Drawer/Modal/表格等 |
|
|
771
|
+
|
|
772
|
+
**evaluate**:浏览器端执行 JS,只能操作 DOM,不能等待/跨 frame/调用 puppeteer
|
|
773
|
+
**rawScript**:Node.js 端执行 async 函数,接收 `(page, frame, params, browser)`,可调用所有 puppeteer API
|
|
774
|
+
**registerCdpScript**:注册可复用脚本,后续通过 cdpScript 步骤按 name 调用
|
|
775
|
+
**cdpScript**:调用 registerCdpScript 注册的脚本或内置脚本
|
|
776
|
+
|
|
777
|
+
### 内置 cdpScript(通用 UI 交互模式)
|
|
778
|
+
|
|
779
|
+
| script | 功能 | params |
|
|
780
|
+
|--------|------|--------|
|
|
781
|
+
| `drawer-action` | Drawer 操作 | `action: 'search'\|'select'\|'confirm'\|'cancel'`, `radioIndex?`, `searchText?`, `searchPlaceholder?` |
|
|
782
|
+
| `modal-action` | Modal/Popconfirm 操作 | `action: 'confirm'\|'cancel'`, `buttonText?` |
|
|
783
|
+
| `switch-tab` | 多标签页切换 | `urlContains?`, `titleIncludes?`, `pageIndex?` |
|
|
784
|
+
| `wait-api` | 等待 API 响应 | `urlPattern`, `timeout?`, `method?` |
|
|
785
|
+
| `get-table-data` | 提取表格数据 | `selector?`, `maxRows?` |
|
|
786
|
+
|
|
787
|
+
**设计原则**:cdpScript 内置通用 UI 交互模式(跨项目复用),业务流程由 LLM 用 rawScript 动态生成
|
|
788
|
+
|
|
789
|
+
```json
|
|
790
|
+
// 内置 cdpScript 示例
|
|
791
|
+
{ "type": "cdpScript", "script": "get-table-data", "params": { "maxRows": 5 } }
|
|
792
|
+
{ "type": "cdpScript", "script": "drawer-action", "params": { "action": "select", "radioIndex": 0 } }
|
|
793
|
+
{ "type": "cdpScript", "script": "drawer-action", "params": { "action": "confirm" } }
|
|
794
|
+
{ "type": "cdpScript", "script": "modal-action", "params": { "action": "confirm" } }
|
|
795
|
+
{ "type": "cdpScript", "script": "switch-tab", "params": { "urlContains": "localhost:8088" } }
|
|
796
|
+
|
|
797
|
+
// rawScript 示例(LLM 动态生成业务逻辑)
|
|
798
|
+
{ "type": "rawScript", "code": "async (page, frame, params, browser) => { var count = await frame.evaluate(() => document.querySelectorAll('tr').length); return { acted: true, passed: count > 0, rowCount: count, status: 'PASS' } }" }
|
|
799
|
+
|
|
800
|
+
// registerCdpScript + cdpScript(先注册再调用)
|
|
801
|
+
{ "type": "registerCdpScript", "name": "my-check", "code": "async (page, frame, params) => { ... }" }
|
|
802
|
+
{ "type": "cdpScript", "script": "my-check", "params": { "id": "123" } }
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
### evaluate 增强
|
|
806
|
+
|
|
807
|
+
```json
|
|
808
|
+
// expectResult:精确匹配返回值
|
|
809
|
+
{ "type": "evaluate", "expression": "...", "expectResult": "found" }
|
|
810
|
+
|
|
811
|
+
// expectResultContains:包含匹配返回值
|
|
812
|
+
{ "type": "evaluate", "expression": "...", "expectResultContains": "数据资源" }
|
|
813
|
+
|
|
814
|
+
// failOnFalsy:返回 falsy 时标记 FAIL
|
|
815
|
+
{ "type": "evaluate", "expression": "...", "failOnFalsy": true }
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
### apiCall(API 直调)
|
|
819
|
+
|
|
820
|
+
```json
|
|
821
|
+
{ "type": "apiCall", "method": "POST", "url": "http://localhost:8088/api/submit", "body": { "id": "123" } }
|
|
822
|
+
// → { status: 200, data: { code: "200" }, passed: true }
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
### switchPage(多标签页切换)
|
|
826
|
+
|
|
827
|
+
```json
|
|
828
|
+
{ "type": "switchPage", "urlContains": "localhost:8088" }
|
|
829
|
+
// → { passed: true, url: "http://localhost:8088/...", title: "..." }
|
|
830
|
+
```
|
|
831
|
+
|
|
735
832
|
## 语义断言(expect)
|
|
736
833
|
|
|
737
834
|
| assertion | 功能 |
|
|
@@ -762,20 +859,26 @@ node local-browser-executor.js --action test-case --test-case-file /tmp/case.jso
|
|
|
762
859
|
{
|
|
763
860
|
"title": "数据集创建 P0",
|
|
764
861
|
"setup": {
|
|
765
|
-
"findFrame": { "
|
|
862
|
+
"findFrame": { "frameUrlContains": "#/catalog/" }
|
|
766
863
|
},
|
|
767
864
|
"dataset": [{ "name": "A" }, { "name": "B" }],
|
|
768
865
|
"steps": [
|
|
769
|
-
{ "type": "
|
|
866
|
+
{ "type": "navigate", "url": "http://localhost:8088/#/szc-ai-data/catalog/catalog-manage", "waitUntil": "domcontentloaded" },
|
|
867
|
+
{ "type": "expect", "assertion": "locator.toBeVisible", "selector": ".ant-table", "timeout": 20000 },
|
|
770
868
|
{ "type": "antInput", "field": "名称", "value": "{{name}}" },
|
|
771
869
|
{ "type": "antSelect", "field": "类型", "option": "图像" },
|
|
772
|
-
{ "type": "act", "click": "text
|
|
870
|
+
{ "type": "act", "click": "text==提 交" },
|
|
773
871
|
{ "type": "expect", "assertion": "validation.toPass" },
|
|
774
|
-
{ "type": "expect", "assertion": "
|
|
872
|
+
{ "type": "expect", "assertion": "locator.toBeVisible", "selector": ".ant-table", "timeout": 15000 }
|
|
775
873
|
]
|
|
776
874
|
}
|
|
777
875
|
```
|
|
778
876
|
|
|
877
|
+
**findFrame 三种方案(按稳定性排序)**:
|
|
878
|
+
- `frameSelector: "iframe"` — 基于 DOM 选择器(最稳定,推荐)
|
|
879
|
+
- `frameUrlContains: "#/catalog/"` — 基于 URL 子串(navigate 后可能失效)
|
|
880
|
+
- `frameContentContains: "数据集目录"` — 基于内容文字(页面未加载时不可用)
|
|
881
|
+
|
|
779
882
|
**关键参数:**
|
|
780
883
|
|
|
781
884
|
- `setup.findFrame.contentContains` — 一次定位子应用 frame
|