@szc-ft/mcp-szcd-client 0.27.3 → 0.27.4

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.
@@ -171,6 +171,51 @@ function parseArgs() {
171
171
  case "--allow-launch":
172
172
  parsed.allowLaunch = true;
173
173
  break;
174
+ case "--task":
175
+ parsed.task = args[++i];
176
+ break;
177
+ case "--menu-api-pattern":
178
+ parsed.menuApiPattern = args[++i];
179
+ break;
180
+ case "--menu-limit":
181
+ parsed.menuLimit = Number.parseInt(args[++i], 10);
182
+ break;
183
+ case "--menu-path-prefix":
184
+ parsed.menuPathPrefix = (parsed.menuPathPrefix || []).concat(args[++i]);
185
+ break;
186
+ case "--menu-filter":
187
+ parsed.menuFilter = (parsed.menuFilter || []).concat(args[++i]);
188
+ break;
189
+ case "--drawer-trigger-selector":
190
+ parsed.drawerTriggerSelector = args[++i];
191
+ break;
192
+ case "--drawer-open-selector":
193
+ parsed.drawerOpenSelector = args[++i];
194
+ break;
195
+ case "--menu-scroll-container":
196
+ parsed.menuScrollContainer = args[++i];
197
+ break;
198
+ case "--use-native-hover":
199
+ parsed.useNativeHover = args[++i] !== "false";
200
+ break;
201
+ case "--max-retry-per-step":
202
+ parsed.maxRetryPerStep = Number.parseInt(args[++i], 10);
203
+ break;
204
+ case "--snapshot-each":
205
+ parsed.snapshotEach = true;
206
+ break;
207
+ case "--screenshot-each":
208
+ parsed.screenshotEach = true;
209
+ break;
210
+ case "--continue-on-fail":
211
+ parsed.continueOnFail = args[++i] !== "false";
212
+ break;
213
+ case "--no-continue-on-fail":
214
+ parsed.continueOnFail = false;
215
+ break;
216
+ case "--capture-input-file":
217
+ parsed.captureInputFile = args[++i];
218
+ break;
174
219
  case "--help":
175
220
  case "-h":
176
221
  printHelp();
@@ -191,11 +236,12 @@ function printHelp() {
191
236
  node local-browser-executor.js --action assert --selector ".ant-table" --expect-visible [options]
192
237
  node local-browser-executor.js --action api-capture --wait 15000 --include-response-body [options]
193
238
  node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0 [options]
239
+ node local-browser-executor.js --action run-task --task menu-api-scan --capture-input-file /tmp/menu-tree.json [options]
194
240
  node local-browser-executor.js --plan '<JSON>' [options]
195
241
  node local-browser-executor.js --plan-file <path> [options]
196
242
 
197
243
  选项:
198
- --action <name> 直接执行动作,当前支持 observe/act/assert/api-capture
244
+ --action <name> 直接执行动作,当前支持 observe/act/assert/api-capture/assert-api/run-task
199
245
  --plan <json> 测试计划 JSON 字符串
200
246
  --plan-file <path> 测试计划 JSON 文件路径
201
247
  --output <path> 结果输出文件路径(默认 stdout)
@@ -238,6 +284,20 @@ function printHelp() {
238
284
  --min-total <n> assert-api 要求捕获请求总数下限
239
285
  --allow-launch action 模式允许无 CDP 时启动 headless Chrome
240
286
  --base-url <url> 基础 URL,替换计划中的 {{baseUrl}} 占位符
287
+ --task <name> run-task 任务名(当前仅支持 menu-api-scan)
288
+ --capture-input-file <path> run-task 读取已抓到的菜单接口 JSON 作为 lastApiCapture
289
+ --menu-api-pattern <regex> run-task 在 lastApiCapture 中筛选菜单接口的正则(默认 listUserViewMenus)
290
+ --menu-limit <n> run-task 仅探索前 N 条菜单
291
+ --menu-path-prefix <text> run-task 仅探索 path 以此开头的菜单(可重复)
292
+ --menu-filter <text> run-task 仅探索 name/path 包含此关键字的菜单(可重复)
293
+ --drawer-trigger-selector <css> run-task / act 抽屉式菜单的触发器选择器
294
+ --drawer-open-selector <css> run-task / act 抽屉打开态选择器(默认 .ant-drawer-open)
295
+ --menu-scroll-container <css> run-task / act 菜单容器需要滚动时的容器选择器
296
+ --use-native-hover <true|false> run-task / act 使用 puppeteer 原生 hover 触发子菜单(默认 true)
297
+ --max-retry-per-step <n> run-task / act 每级菜单查找重试次数(默认 2)
298
+ --snapshot-each run-task 每条菜单成功后做一次轻量 observe
299
+ --screenshot-each run-task 失败时截图(依赖 outputDir,默认关闭)
300
+ --no-continue-on-fail run-task 第一条失败即终止(默认遇错继续)
241
301
  -h, --help 显示帮助
242
302
  `);
243
303
  }
@@ -454,11 +514,52 @@ async function executeAction(args) {
454
514
  });
455
515
  return { ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
456
516
  }
517
+ case "run-task":
518
+ case "runTask": {
519
+ // 把外部 captureFile 当作 lastApiCapture 注入 context
520
+ const context = {};
521
+ if (args.captureInputFile) {
522
+ const fs = await import("node:fs");
523
+ try {
524
+ context.lastApiCapture = JSON.parse(fs.readFileSync(args.captureInputFile, "utf8"));
525
+ } catch (err) {
526
+ return {
527
+ error: `读取 capture 输入文件失败: ${err.message}`,
528
+ errorCode: "CAPTURE_INPUT_INVALID",
529
+ startTime: startedAt,
530
+ endTime: new Date().toISOString(),
531
+ };
532
+ }
533
+ }
534
+ const result = await engine.runTask({
535
+ task: args.task || "menu-api-scan",
536
+ menuApiPattern: args.menuApiPattern,
537
+ menuLimit: args.menuLimit,
538
+ menuPathPrefix: args.menuPathPrefix,
539
+ menuFilter: args.menuFilter,
540
+ drawerTriggerSelector: args.drawerTriggerSelector,
541
+ drawerOpenSelector: args.drawerOpenSelector,
542
+ menuScrollContainer: args.menuScrollContainer,
543
+ useNativeHover: args.useNativeHover,
544
+ maxRetryPerStep: args.maxRetryPerStep,
545
+ snapshotEach: args.snapshotEach,
546
+ screenshotEach: args.screenshotEach,
547
+ continueOnFail: args.continueOnFail,
548
+ apiCaptureOptions: {
549
+ wait: args.wait,
550
+ targetUrlContains: args.targetUrlContains,
551
+ includeAllTargets: args.includeAllTargets,
552
+ includeResponseBody: args.includeResponseBody !== false,
553
+ urlPattern: args.urlPattern,
554
+ },
555
+ }, context);
556
+ return { type: "runTask", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
557
+ }
457
558
  default:
458
559
  return {
459
560
  error: `未知 action: ${args.action}`,
460
561
  errorCode: "UNKNOWN_ACTION",
461
- supportedActions: ["observe", "act", "assert", "api-capture"],
562
+ supportedActions: ["observe", "act", "assert", "api-capture", "assert-api", "run-task"],
462
563
  startTime: startedAt,
463
564
  endTime: new Date().toISOString(),
464
565
  };
@@ -95,6 +95,7 @@ AI 直接构造测试计划 JSON:
95
95
  | findFrame | 查找微前端 iframe | `urlIncludes` 或 `urlRegex` 或 `titleIncludes` 或 `contentIncludes` 或 `frameIndex` | `{ found, frameUrl, matchedBy, totalFrames }` |
96
96
  | loginWait | 等待 SSO 登录完成 | `loginSelector`, `timeout`, `interval` | `{ loggedIn, elapsed, currentUrl }` |
97
97
  | aiAssert | 语义断言(截图+审查) | `assertion`, `filename` | `{ screenshotPath, assertion, needsVisualReview }` |
98
+ | runTask | 高层任务 runner(批量动态菜单扫描) | `task: "menu-api-scan"`, `menuApiPattern`, `menuLimit`, `menuPathPrefix[]`, `menuFilter[]`, `drawerTriggerSelector`, `useNativeHover`, `apiCaptureOptions`, `continueOnFail` | `{ task, discovered, attempted, byOutcome, results[] }` |
98
99
 
99
100
  ### 场景选择策略
100
101
 
@@ -488,3 +489,71 @@ cd ~/.szcd-mcp/deps && npm install --registry=https://registry.npmmirror.com --@
488
489
  "outputDir": "/tmp/browser-test-xxx"
489
490
  }
490
491
  ```
492
+
493
+ ## 批量动态菜单扫描(runTask)
494
+
495
+ 适用场景:测试系统有 N 条动态路由(来自后端菜单接口),要逐条点击触发并验证接口是否返回 200。6/18 反馈中,AI 最终用 130 余条 `act --menu-path` + `api-capture` 手工组合走完 129 条菜单——`runTask` 把这套流水固化为单步骤。
496
+
497
+ 工作流:
498
+
499
+ 1. **第一步:先抓菜单接口**(一次即可,作为整张菜单树的来源)
500
+ ```bash
501
+ node local-browser-executor.js --action api-capture \
502
+ --url-pattern "listUserViewMenus" \
503
+ --include-response-body \
504
+ --include-all-targets \
505
+ --reload --wait 8000 \
506
+ --output /tmp/menu-tree.json
507
+ ```
508
+
509
+ 2. **第二步:把菜单树喂给 runTask**
510
+ ```bash
511
+ node local-browser-executor.js --action run-task \
512
+ --task menu-api-scan \
513
+ --capture-input-file /tmp/menu-tree.json \
514
+ --menu-path-prefix /catalog \
515
+ --menu-limit 20 \
516
+ --drawer-trigger-selector "[data-test=menu-toggle]" \
517
+ --use-native-hover true \
518
+ --max-retry-per-step 2 \
519
+ --wait 4000 \
520
+ --output /tmp/menu-scan-report.json
521
+ ```
522
+
523
+ 3. **结果解读**:`byOutcome` 给出四档统计:
524
+ - `apiOk`:菜单点开 + 接口 200 ✅
525
+ - `apiFailed`:菜单点开 + 接口 ≥400(看 `results[i].api.requests` 找具体 URL)
526
+ - `noApi`:菜单点开但没产生任何 XHR(可能是纯静态页或路由没触发请求)
527
+ - `clickFailed`:菜单点不开(看 `results[i].menuResult.failedAt` + `candidates`)
528
+
529
+ 4. **plan JSON 形式**(也可作为 `--plan-file` 一步:先 apiCapture 再 runTask):
530
+ ```json
531
+ {
532
+ "steps": [
533
+ { "type": "apiCapture", "urlPattern": "listUserViewMenus", "includeResponseBody": true, "reload": true, "wait": 8000, "includeAllTargets": true },
534
+ { "type": "runTask", "task": "menu-api-scan", "menuLimit": 30, "drawerTriggerSelector": "[data-test=menu-toggle]" }
535
+ ]
536
+ }
537
+ ```
538
+ `runTask` 会自动从 `context.lastApiCapture` 拿菜单树,不需要再传文件。
539
+
540
+ ## 反模式备忘(来自 6/17 + 6/18 实战反馈)
541
+
542
+ | 错误做法 | 正确做法 | 原因 |
543
+ |---------|---------|------|
544
+ | `setRequestInterception(true)` 抓接口 | 仅 CDP `Network.responseReceived` + `getResponseBody` | request interception 会拦下 wujie iframe 的 RPC 帧造成挂起 |
545
+ | `page.click("text=保存")` 不加可见性过滤 | 默认走 `act` 的 `text=`,已自动只匹配 `visible+enabled` 的元素 | 隐藏在收起菜单/未渲染抽屉里的同名文本会被错误命中 |
546
+ | 多级菜单只用一次 `dispatchEvent("mouseover")` | `useNativeHover: true`(默认) + `drawerTriggerSelector` 每级前重开 | Vue 的 v-on:mouseenter 对 dispatchEvent 不触发;抽屉式菜单点完一次会自动收起 |
547
+ | `setTimeout(awaitPage.bringToFront(), 30000)` 之外不做兜底 | 调用前自查"目标页是否还活着"(`findPage` / `_findFrame`),失败立刻退而求其次操作主帧 | bringToFront 在 OS 抢焦点失败时会无限挂起,没超时 |
548
+ | iframe attach 失败就报错退出 | `attachRetries` 默认 3 次、间隔 1.5s 重抓 `browser.targets()` | wujie 的 blob iframe target 在主页面 ready 后才注册,时序敏感 |
549
+ | 大批量探索每条都 throw | `_actMenuPath` 失败时返回 `acted:false + candidates`,配合 `runTask --continue-on-fail` | 探索性扫描遇到一条点不开就全部中断,没法发现整体面 |
550
+
551
+ ## 非目标声明
552
+
553
+ `local-browser-test` **不**承担以下职责,遇到时显式拒绝并指给正确工具:
554
+
555
+ - ❌ 接口压力测试 / 并发性能验证 → 用 k6、wrk、jmeter
556
+ - ❌ 跨浏览器兼容性矩阵(Safari/Firefox 多版本)→ BrowserStack / SauceLabs
557
+ - ❌ 单元测试 / 组件级测试 → Vitest / Jest / RTL
558
+ - ❌ 视觉回归全量对比(无 baseline 设计稿)→ Percy / Chromatic
559
+ - ✅ 真实浏览器内的功能验证、API 时序捕获、设计稿单图对比、动态菜单批量扫描
@@ -38,6 +38,21 @@
38
38
  * --max-rounds <n> 最大轮数(默认 60)
39
39
  */
40
40
 
41
+ // 共享缓存自愈:6/17 反馈中 NODE_PATH 未注入会导致 puppeteer-core MODULE_NOT_FOUND。
42
+ // 把 ~/.szcd-mcp/deps/node_modules 注入 module 解析路径,再 require puppeteer-core。
43
+ (function ensureSharedDeps() {
44
+ try {
45
+ const os = require('os');
46
+ const Module = require('module');
47
+ const sharedDeps = require('path').join(os.homedir(), '.szcd-mcp', 'deps', 'node_modules');
48
+ if (require('fs').existsSync(sharedDeps) && !Module.globalPaths.includes(sharedDeps)) {
49
+ Module.globalPaths.push(sharedDeps);
50
+ }
51
+ } catch {
52
+ // ignore — 没有共享缓存时退回项目本地 node_modules
53
+ }
54
+ })();
55
+
41
56
  const puppeteer = require('puppeteer-core');
42
57
  const fs = require('fs');
43
58
  const path = require('path');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@szc-ft/mcp-szcd-client",
3
- "version": "0.27.3",
3
+ "version": "0.27.4",
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.27.3",
3
+ "version": "0.27.4",
4
4
  "description": "szcd 组件库 MCP 助手 — 查询组件信息、匹配需求、生成代码",
5
5
  "mcpServers": {
6
6
  "szcd-component-helper": {
@@ -95,6 +95,7 @@ AI 直接构造测试计划 JSON:
95
95
  | findFrame | 查找微前端 iframe | `urlIncludes` 或 `urlRegex` 或 `titleIncludes` 或 `contentIncludes` 或 `frameIndex` | `{ found, frameUrl, matchedBy, totalFrames }` |
96
96
  | loginWait | 等待 SSO 登录完成 | `loginSelector`, `timeout`, `interval` | `{ loggedIn, elapsed, currentUrl }` |
97
97
  | aiAssert | 语义断言(截图+审查) | `assertion`, `filename` | `{ screenshotPath, assertion, needsVisualReview }` |
98
+ | runTask | 高层任务 runner(批量动态菜单扫描) | `task: "menu-api-scan"`, `menuApiPattern`, `menuLimit`, `menuPathPrefix[]`, `menuFilter[]`, `drawerTriggerSelector`, `useNativeHover`, `apiCaptureOptions`, `continueOnFail` | `{ task, discovered, attempted, byOutcome, results[] }` |
98
99
 
99
100
  ### 场景选择策略
100
101
 
@@ -488,3 +489,71 @@ cd ~/.szcd-mcp/deps && npm install --registry=https://registry.npmmirror.com --@
488
489
  "outputDir": "/tmp/browser-test-xxx"
489
490
  }
490
491
  ```
492
+
493
+ ## 批量动态菜单扫描(runTask)
494
+
495
+ 适用场景:测试系统有 N 条动态路由(来自后端菜单接口),要逐条点击触发并验证接口是否返回 200。6/18 反馈中,AI 最终用 130 余条 `act --menu-path` + `api-capture` 手工组合走完 129 条菜单——`runTask` 把这套流水固化为单步骤。
496
+
497
+ 工作流:
498
+
499
+ 1. **第一步:先抓菜单接口**(一次即可,作为整张菜单树的来源)
500
+ ```bash
501
+ node local-browser-executor.js --action api-capture \
502
+ --url-pattern "listUserViewMenus" \
503
+ --include-response-body \
504
+ --include-all-targets \
505
+ --reload --wait 8000 \
506
+ --output /tmp/menu-tree.json
507
+ ```
508
+
509
+ 2. **第二步:把菜单树喂给 runTask**
510
+ ```bash
511
+ node local-browser-executor.js --action run-task \
512
+ --task menu-api-scan \
513
+ --capture-input-file /tmp/menu-tree.json \
514
+ --menu-path-prefix /catalog \
515
+ --menu-limit 20 \
516
+ --drawer-trigger-selector "[data-test=menu-toggle]" \
517
+ --use-native-hover true \
518
+ --max-retry-per-step 2 \
519
+ --wait 4000 \
520
+ --output /tmp/menu-scan-report.json
521
+ ```
522
+
523
+ 3. **结果解读**:`byOutcome` 给出四档统计:
524
+ - `apiOk`:菜单点开 + 接口 200 ✅
525
+ - `apiFailed`:菜单点开 + 接口 ≥400(看 `results[i].api.requests` 找具体 URL)
526
+ - `noApi`:菜单点开但没产生任何 XHR(可能是纯静态页或路由没触发请求)
527
+ - `clickFailed`:菜单点不开(看 `results[i].menuResult.failedAt` + `candidates`)
528
+
529
+ 4. **plan JSON 形式**(也可作为 `--plan-file` 一步:先 apiCapture 再 runTask):
530
+ ```json
531
+ {
532
+ "steps": [
533
+ { "type": "apiCapture", "urlPattern": "listUserViewMenus", "includeResponseBody": true, "reload": true, "wait": 8000, "includeAllTargets": true },
534
+ { "type": "runTask", "task": "menu-api-scan", "menuLimit": 30, "drawerTriggerSelector": "[data-test=menu-toggle]" }
535
+ ]
536
+ }
537
+ ```
538
+ `runTask` 会自动从 `context.lastApiCapture` 拿菜单树,不需要再传文件。
539
+
540
+ ## 反模式备忘(来自 6/17 + 6/18 实战反馈)
541
+
542
+ | 错误做法 | 正确做法 | 原因 |
543
+ |---------|---------|------|
544
+ | `setRequestInterception(true)` 抓接口 | 仅 CDP `Network.responseReceived` + `getResponseBody` | request interception 会拦下 wujie iframe 的 RPC 帧造成挂起 |
545
+ | `page.click("text=保存")` 不加可见性过滤 | 默认走 `act` 的 `text=`,已自动只匹配 `visible+enabled` 的元素 | 隐藏在收起菜单/未渲染抽屉里的同名文本会被错误命中 |
546
+ | 多级菜单只用一次 `dispatchEvent("mouseover")` | `useNativeHover: true`(默认) + `drawerTriggerSelector` 每级前重开 | Vue 的 v-on:mouseenter 对 dispatchEvent 不触发;抽屉式菜单点完一次会自动收起 |
547
+ | `setTimeout(awaitPage.bringToFront(), 30000)` 之外不做兜底 | 调用前自查"目标页是否还活着"(`findPage` / `_findFrame`),失败立刻退而求其次操作主帧 | bringToFront 在 OS 抢焦点失败时会无限挂起,没超时 |
548
+ | iframe attach 失败就报错退出 | `attachRetries` 默认 3 次、间隔 1.5s 重抓 `browser.targets()` | wujie 的 blob iframe target 在主页面 ready 后才注册,时序敏感 |
549
+ | 大批量探索每条都 throw | `_actMenuPath` 失败时返回 `acted:false + candidates`,配合 `runTask --continue-on-fail` | 探索性扫描遇到一条点不开就全部中断,没法发现整体面 |
550
+
551
+ ## 非目标声明
552
+
553
+ `local-browser-test` **不**承担以下职责,遇到时显式拒绝并指给正确工具:
554
+
555
+ - ❌ 接口压力测试 / 并发性能验证 → 用 k6、wrk、jmeter
556
+ - ❌ 跨浏览器兼容性矩阵(Safari/Firefox 多版本)→ BrowserStack / SauceLabs
557
+ - ❌ 单元测试 / 组件级测试 → Vitest / Jest / RTL
558
+ - ❌ 视觉回归全量对比(无 baseline 设计稿)→ Percy / Chromatic
559
+ - ✅ 真实浏览器内的功能验证、API 时序捕获、设计稿单图对比、动态菜单批量扫描
@@ -38,6 +38,21 @@
38
38
  * --max-rounds <n> 最大轮数(默认 60)
39
39
  */
40
40
 
41
+ // 共享缓存自愈:6/17 反馈中 NODE_PATH 未注入会导致 puppeteer-core MODULE_NOT_FOUND。
42
+ // 把 ~/.szcd-mcp/deps/node_modules 注入 module 解析路径,再 require puppeteer-core。
43
+ (function ensureSharedDeps() {
44
+ try {
45
+ const os = require('os');
46
+ const Module = require('module');
47
+ const sharedDeps = require('path').join(os.homedir(), '.szcd-mcp', 'deps', 'node_modules');
48
+ if (require('fs').existsSync(sharedDeps) && !Module.globalPaths.includes(sharedDeps)) {
49
+ Module.globalPaths.push(sharedDeps);
50
+ }
51
+ } catch {
52
+ // ignore — 没有共享缓存时退回项目本地 node_modules
53
+ }
54
+ })();
55
+
41
56
  const puppeteer = require('puppeteer-core');
42
57
  const fs = require('fs');
43
58
  const path = require('path');
@@ -95,6 +95,7 @@ AI 直接构造测试计划 JSON:
95
95
  | findFrame | 查找微前端 iframe | `urlIncludes` 或 `urlRegex` 或 `titleIncludes` 或 `contentIncludes` 或 `frameIndex` | `{ found, frameUrl, matchedBy, totalFrames }` |
96
96
  | loginWait | 等待 SSO 登录完成 | `loginSelector`, `timeout`, `interval` | `{ loggedIn, elapsed, currentUrl }` |
97
97
  | aiAssert | 语义断言(截图+审查) | `assertion`, `filename` | `{ screenshotPath, assertion, needsVisualReview }` |
98
+ | runTask | 高层任务 runner(批量动态菜单扫描) | `task: "menu-api-scan"`, `menuApiPattern`, `menuLimit`, `menuPathPrefix[]`, `menuFilter[]`, `drawerTriggerSelector`, `useNativeHover`, `apiCaptureOptions`, `continueOnFail` | `{ task, discovered, attempted, byOutcome, results[] }` |
98
99
 
99
100
  ### 场景选择策略
100
101
 
@@ -488,3 +489,71 @@ cd ~/.szcd-mcp/deps && npm install --registry=https://registry.npmmirror.com --@
488
489
  "outputDir": "/tmp/browser-test-xxx"
489
490
  }
490
491
  ```
492
+
493
+ ## 批量动态菜单扫描(runTask)
494
+
495
+ 适用场景:测试系统有 N 条动态路由(来自后端菜单接口),要逐条点击触发并验证接口是否返回 200。6/18 反馈中,AI 最终用 130 余条 `act --menu-path` + `api-capture` 手工组合走完 129 条菜单——`runTask` 把这套流水固化为单步骤。
496
+
497
+ 工作流:
498
+
499
+ 1. **第一步:先抓菜单接口**(一次即可,作为整张菜单树的来源)
500
+ ```bash
501
+ node local-browser-executor.js --action api-capture \
502
+ --url-pattern "listUserViewMenus" \
503
+ --include-response-body \
504
+ --include-all-targets \
505
+ --reload --wait 8000 \
506
+ --output /tmp/menu-tree.json
507
+ ```
508
+
509
+ 2. **第二步:把菜单树喂给 runTask**
510
+ ```bash
511
+ node local-browser-executor.js --action run-task \
512
+ --task menu-api-scan \
513
+ --capture-input-file /tmp/menu-tree.json \
514
+ --menu-path-prefix /catalog \
515
+ --menu-limit 20 \
516
+ --drawer-trigger-selector "[data-test=menu-toggle]" \
517
+ --use-native-hover true \
518
+ --max-retry-per-step 2 \
519
+ --wait 4000 \
520
+ --output /tmp/menu-scan-report.json
521
+ ```
522
+
523
+ 3. **结果解读**:`byOutcome` 给出四档统计:
524
+ - `apiOk`:菜单点开 + 接口 200 ✅
525
+ - `apiFailed`:菜单点开 + 接口 ≥400(看 `results[i].api.requests` 找具体 URL)
526
+ - `noApi`:菜单点开但没产生任何 XHR(可能是纯静态页或路由没触发请求)
527
+ - `clickFailed`:菜单点不开(看 `results[i].menuResult.failedAt` + `candidates`)
528
+
529
+ 4. **plan JSON 形式**(也可作为 `--plan-file` 一步:先 apiCapture 再 runTask):
530
+ ```json
531
+ {
532
+ "steps": [
533
+ { "type": "apiCapture", "urlPattern": "listUserViewMenus", "includeResponseBody": true, "reload": true, "wait": 8000, "includeAllTargets": true },
534
+ { "type": "runTask", "task": "menu-api-scan", "menuLimit": 30, "drawerTriggerSelector": "[data-test=menu-toggle]" }
535
+ ]
536
+ }
537
+ ```
538
+ `runTask` 会自动从 `context.lastApiCapture` 拿菜单树,不需要再传文件。
539
+
540
+ ## 反模式备忘(来自 6/17 + 6/18 实战反馈)
541
+
542
+ | 错误做法 | 正确做法 | 原因 |
543
+ |---------|---------|------|
544
+ | `setRequestInterception(true)` 抓接口 | 仅 CDP `Network.responseReceived` + `getResponseBody` | request interception 会拦下 wujie iframe 的 RPC 帧造成挂起 |
545
+ | `page.click("text=保存")` 不加可见性过滤 | 默认走 `act` 的 `text=`,已自动只匹配 `visible+enabled` 的元素 | 隐藏在收起菜单/未渲染抽屉里的同名文本会被错误命中 |
546
+ | 多级菜单只用一次 `dispatchEvent("mouseover")` | `useNativeHover: true`(默认) + `drawerTriggerSelector` 每级前重开 | Vue 的 v-on:mouseenter 对 dispatchEvent 不触发;抽屉式菜单点完一次会自动收起 |
547
+ | `setTimeout(awaitPage.bringToFront(), 30000)` 之外不做兜底 | 调用前自查"目标页是否还活着"(`findPage` / `_findFrame`),失败立刻退而求其次操作主帧 | bringToFront 在 OS 抢焦点失败时会无限挂起,没超时 |
548
+ | iframe attach 失败就报错退出 | `attachRetries` 默认 3 次、间隔 1.5s 重抓 `browser.targets()` | wujie 的 blob iframe target 在主页面 ready 后才注册,时序敏感 |
549
+ | 大批量探索每条都 throw | `_actMenuPath` 失败时返回 `acted:false + candidates`,配合 `runTask --continue-on-fail` | 探索性扫描遇到一条点不开就全部中断,没法发现整体面 |
550
+
551
+ ## 非目标声明
552
+
553
+ `local-browser-test` **不**承担以下职责,遇到时显式拒绝并指给正确工具:
554
+
555
+ - ❌ 接口压力测试 / 并发性能验证 → 用 k6、wrk、jmeter
556
+ - ❌ 跨浏览器兼容性矩阵(Safari/Firefox 多版本)→ BrowserStack / SauceLabs
557
+ - ❌ 单元测试 / 组件级测试 → Vitest / Jest / RTL
558
+ - ❌ 视觉回归全量对比(无 baseline 设计稿)→ Percy / Chromatic
559
+ - ✅ 真实浏览器内的功能验证、API 时序捕获、设计稿单图对比、动态菜单批量扫描
@@ -38,6 +38,21 @@
38
38
  * --max-rounds <n> 最大轮数(默认 60)
39
39
  */
40
40
 
41
+ // 共享缓存自愈:6/17 反馈中 NODE_PATH 未注入会导致 puppeteer-core MODULE_NOT_FOUND。
42
+ // 把 ~/.szcd-mcp/deps/node_modules 注入 module 解析路径,再 require puppeteer-core。
43
+ (function ensureSharedDeps() {
44
+ try {
45
+ const os = require('os');
46
+ const Module = require('module');
47
+ const sharedDeps = require('path').join(os.homedir(), '.szcd-mcp', 'deps', 'node_modules');
48
+ if (require('fs').existsSync(sharedDeps) && !Module.globalPaths.includes(sharedDeps)) {
49
+ Module.globalPaths.push(sharedDeps);
50
+ }
51
+ } catch {
52
+ // ignore — 没有共享缓存时退回项目本地 node_modules
53
+ }
54
+ })();
55
+
41
56
  const puppeteer = require('puppeteer-core');
42
57
  const fs = require('fs');
43
58
  const path = require('path');