@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.
@@ -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.browser.pages();
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
- return this._actMenuPath(targetFrame, options);
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.browser.pages();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@szc-ft/mcp-szcd-client",
3
- "version": "0.28.2",
3
+ "version": "0.29.0",
4
4
  "description": "MCP client for szcd component library - auto-configures AI coding tools with MCP server, skills, agents and commands",
5
5
  "keywords": [
6
6
  "mcp",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "szcd-component-helper",
3
- "version": "0.28.2",
3
+ "version": "0.29.0",
4
4
  "description": "szcd 组件库 MCP 助手 — 查询组件信息、匹配需求、生成代码",
5
5
  "mcpServers": {
6
6
  "szcd-component-helper": {
@@ -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 | `urlIncludes` 或 `urlRegex` 或 `titleIncludes` 或 `contentIncludes` 或 `frameIndex` | `{ found, frameUrl, matchedBy, totalFrames }` |
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
- 1. **selector 必须从用户代码中读取,不要猜测**
250
- - 读代码中的 `id`、`className`、`data-testid`
251
- - szcd 组件的 class 可通过 `get_component_full_profile` 获取
252
- 2. **API 路径从代码中的接口调用读取**
253
- 3. **checkElement 的 expect 从代码逻辑推断**
254
- - columns 定义了 N 列 → `expect: { minCount: N }`(检查 th 数量)
255
- - dataSource 有数据 → `expect: { minCount: 1 }`(检查行数)
256
- 4. **compare 的 regions 从设计稿分析结果或组件布局推断**
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` 触发(不是 click)
697
- - 选项异步渲染,内置 15×200ms 轮询
698
- - 每次操作前后自动 `closeAllDropdowns`,`act` 入口检测残留并预清理
699
- - 操作后 VERIFY `.ant-select-selection-item[title]` 回写
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": { "contentContains": "数据集目录" }
862
+ "findFrame": { "frameUrlContains": "#/catalog/" }
766
863
  },
767
864
  "dataset": [{ "name": "A" }, { "name": "B" }],
768
865
  "steps": [
769
- { "type": "apiCapture", "urlPattern": "/api/dataset", "wait": 5000, "includeAllTargets": true },
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": "api.toAllPass", "scope": "current-frame" }
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