@szc-ft/mcp-szcd-client 0.27.2 → 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.
- package/lib/browser-engine.js +703 -3
- package/local-browser-executor.js +231 -3
- package/opencode-extension/skills/local-browser-test/SKILL.md +112 -1
- package/opencode-extension/skills/local-browser-test/local-browser-executor.cjs +15 -0
- package/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +112 -1
- package/qwen-extension/skills/local-browser-test/local-browser-executor.cjs +15 -0
- package/standard-skill/local-browser-test/SKILL.md +112 -1
- package/standard-skill/local-browser-test/local-browser-executor.cjs +15 -0
- package/opencode-extension/skills/local-browser-test/local-browser-executor-old.cjs +0 -395
- package/qwen-extension/skills/local-browser-test/local-browser-executor-old.cjs +0 -395
- package/standard-skill/local-browser-test/local-browser-executor-old.cjs +0 -395
|
@@ -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');
|
|
@@ -79,8 +79,10 @@ AI 直接构造测试计划 JSON:
|
|
|
79
79
|
| type | 功能 | 参数 | 返回 |
|
|
80
80
|
|------|------|------|------|
|
|
81
81
|
| observe | 结构化观察 | `selector`, `depth`, `frameContentContains`, `maxInteractiveElements` | `{ frames, tree, interactiveElements[] }` |
|
|
82
|
-
| act | 按 index/selector 交互 | `index` 或 `selector/click/hover`, `typeText`, `clear`, `frameContentContains` | `{ acted, action, element }` |
|
|
82
|
+
| act | 按 index/selector/menuPath 交互 | `index` 或 `selector/click/hover` 或 `menuPath`, `typeText`, `clear`, `frameContentContains` | `{ acted, action, element/steps }` |
|
|
83
83
|
| assert | 轻量断言 | `selector`, `expect`, `urlContains`, `textContains` | `{ passed, checks }` |
|
|
84
|
+
| apiCapture | API 请求/响应捕获 | `wait`, `urlPattern`, `method`, `includeResponseBody`, `frameContentContains` | `{ targets, requests, summary }` |
|
|
85
|
+
| assertApi | API 捕获结果断言 | `maxFailed`, `requiredUrls`, `forbidStatusGte`, `minTotal` | `{ passed, checks, summary }` |
|
|
84
86
|
| navigate | 导航到 URL | `url`, `waitUntil` | `{ url, title, loadTime }` |
|
|
85
87
|
| screenshot | 页面截图 | `fullPage`, `clip`, `filename` | `{ filepath, width, height }` |
|
|
86
88
|
| compare | 设计稿对比 | `designImagePath`, `threshold`, `regions[]` | `{ fidelity, diffPixels, diffImagePath, regions[] }` |
|
|
@@ -93,6 +95,7 @@ AI 直接构造测试计划 JSON:
|
|
|
93
95
|
| findFrame | 查找微前端 iframe | `urlIncludes` 或 `urlRegex` 或 `titleIncludes` 或 `contentIncludes` 或 `frameIndex` | `{ found, frameUrl, matchedBy, totalFrames }` |
|
|
94
96
|
| loginWait | 等待 SSO 登录完成 | `loginSelector`, `timeout`, `interval` | `{ loggedIn, elapsed, currentUrl }` |
|
|
95
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[] }` |
|
|
96
99
|
|
|
97
100
|
### 场景选择策略
|
|
98
101
|
|
|
@@ -236,6 +239,18 @@ node {client_path}/local-browser-executor.js \
|
|
|
236
239
|
--output /tmp/browser-act-observe.json
|
|
237
240
|
```
|
|
238
241
|
|
|
242
|
+
### 左侧菜单路径稳定点击
|
|
243
|
+
```bash
|
|
244
|
+
node {client_path}/local-browser-executor.js \
|
|
245
|
+
--action act \
|
|
246
|
+
--url-contains "platform.aicityos.com" \
|
|
247
|
+
--menu-path "目录管理/数据集目录" \
|
|
248
|
+
--observe-after \
|
|
249
|
+
--output /tmp/browser-menu-act.json
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`--menu-path` 会按路径逐级匹配菜单项,非末级优先执行展开,末级执行点击;匹配优先考虑 `role=menuitem`、`.ant-menu-item`、`.ant-menu-submenu-title` 和文本精确/包含匹配。
|
|
253
|
+
|
|
239
254
|
### 轻量 DOM 断言
|
|
240
255
|
```bash
|
|
241
256
|
node {client_path}/local-browser-executor.js \
|
|
@@ -247,6 +262,34 @@ node {client_path}/local-browser-executor.js \
|
|
|
247
262
|
--output /tmp/browser-assert.json
|
|
248
263
|
```
|
|
249
264
|
|
|
265
|
+
### API 请求/响应捕获(真实浏览器 + 微前端)
|
|
266
|
+
```bash
|
|
267
|
+
node {client_path}/local-browser-executor.js \
|
|
268
|
+
--action api-capture \
|
|
269
|
+
--url-contains "platform.aicityos.com" \
|
|
270
|
+
--frame-content-contains "数据集目录" \
|
|
271
|
+
--url-pattern "/api/" \
|
|
272
|
+
--wait 15000 \
|
|
273
|
+
--include-response-body \
|
|
274
|
+
--reload \
|
|
275
|
+
--output /tmp/browser-api-capture.json
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`api-capture` 使用 CDP `Network.*` 事件监听主页面和可 attach 的 page/iframe target,不使用 `setRequestInterception`,避免阻断真实请求。wujie/qiankun 场景优先传 `--frame-content-contains` 锁定业务 iframe;如需监听全部 target,追加 `--include-all-targets`。
|
|
279
|
+
|
|
280
|
+
### API 捕获结果断言
|
|
281
|
+
```bash
|
|
282
|
+
node {client_path}/local-browser-executor.js \
|
|
283
|
+
--action assert-api \
|
|
284
|
+
--capture-file /tmp/browser-api-capture.json \
|
|
285
|
+
--max-failed 0 \
|
|
286
|
+
--forbid-status-gte 400 \
|
|
287
|
+
--required-url "/list" \
|
|
288
|
+
--output /tmp/browser-api-assert.json
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
计划内可直接在 `apiCapture` 后追加 `{ "type": "assertApi", "maxFailed": 0, "forbidStatusGte": 400 }`,会自动读取上一步捕获结果。
|
|
292
|
+
|
|
250
293
|
### 快速诊断(过渡兼容)
|
|
251
294
|
```bash
|
|
252
295
|
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
@@ -446,3 +489,71 @@ cd ~/.szcd-mcp/deps && npm install --registry=https://registry.npmmirror.com --@
|
|
|
446
489
|
"outputDir": "/tmp/browser-test-xxx"
|
|
447
490
|
}
|
|
448
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');
|
|
@@ -1,395 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* local-browser-executor.js - 本地浏览器自动化执行器
|
|
4
|
-
*
|
|
5
|
-
* 功能:
|
|
6
|
-
* --action diagnose 全面诊断(JS错误、网络请求、API检查、资源加载)
|
|
7
|
-
* --action list-pages 列出所有标签页
|
|
8
|
-
* --action screenshot 截图
|
|
9
|
-
* --action api-check 检查 API 请求状态
|
|
10
|
-
* --action plan 执行测试计划 JSON
|
|
11
|
-
*
|
|
12
|
-
* 连接方式:
|
|
13
|
-
* --cdp-url http://localhost:9222 (默认自动检测 9222)
|
|
14
|
-
* --auto-detect 自动检测 Chrome/Edge 进程
|
|
15
|
-
*
|
|
16
|
-
* 通用参数:
|
|
17
|
-
* --url <url> 目标页面 URL
|
|
18
|
-
* --url-contains <s> 按关键词查找已打开的页面
|
|
19
|
-
* --wait <ms> 额外等待时间(默认 5000)
|
|
20
|
-
* --output <path> 结果输出文件
|
|
21
|
-
* --output-dir <dir> 截图/产物输出目录
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
const puppeteer = require('puppeteer-core');
|
|
25
|
-
const fs = require('fs');
|
|
26
|
-
const path = require('path');
|
|
27
|
-
const http = require('http');
|
|
28
|
-
|
|
29
|
-
// ===== 参数解析 =====
|
|
30
|
-
const args = process.argv.slice(2);
|
|
31
|
-
function getArg(name, defaultVal) {
|
|
32
|
-
const idx = args.indexOf(`--${name}`);
|
|
33
|
-
if (idx === -1) return defaultVal;
|
|
34
|
-
return args[idx + 1] || defaultVal;
|
|
35
|
-
}
|
|
36
|
-
function hasFlag(name) {
|
|
37
|
-
return args.includes(`--${name}`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const ACTION = getArg('action', 'diagnose');
|
|
41
|
-
const CDP_URL = getArg('cdp-url', 'http://localhost:9222');
|
|
42
|
-
const TARGET_URL = getArg('url', null);
|
|
43
|
-
const URL_CONTAINS = getArg('url-contains', null);
|
|
44
|
-
const WAIT_MS = parseInt(getArg('wait', '5000'), 10);
|
|
45
|
-
const OUTPUT = getArg('output', null);
|
|
46
|
-
const OUTPUT_DIR = getArg('output-dir', '/tmp/browser-test');
|
|
47
|
-
const PLAN_FILE = getArg('plan-file', null);
|
|
48
|
-
|
|
49
|
-
// ===== 工具函数 =====
|
|
50
|
-
function log(msg) { console.log(msg); }
|
|
51
|
-
function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); }
|
|
52
|
-
|
|
53
|
-
function checkCDP(url) {
|
|
54
|
-
return new Promise((resolve) => {
|
|
55
|
-
const req = http.get(url + '/json/version', { timeout: 2000 }, (res) => {
|
|
56
|
-
let data = '';
|
|
57
|
-
res.on('data', c => data += c);
|
|
58
|
-
res.on('end', () => {
|
|
59
|
-
try { resolve(JSON.parse(data)); } catch { resolve(null); }
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
req.on('error', () => resolve(null));
|
|
63
|
-
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function findPage(browser) {
|
|
68
|
-
const pages = await browser.pages();
|
|
69
|
-
if (TARGET_URL) {
|
|
70
|
-
for (const p of pages) {
|
|
71
|
-
if (p.url().includes(TARGET_URL) || p.url() === TARGET_URL) return p;
|
|
72
|
-
}
|
|
73
|
-
// 导航到目标 URL
|
|
74
|
-
const p = pages[0] || await browser.newPage();
|
|
75
|
-
await p.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
76
|
-
return p;
|
|
77
|
-
}
|
|
78
|
-
if (URL_CONTAINS) {
|
|
79
|
-
for (const p of pages) {
|
|
80
|
-
if (p.url().includes(URL_CONTAINS)) return p;
|
|
81
|
-
}
|
|
82
|
-
log(`[WARN] 未找到包含 "${URL_CONTAINS}" 的页面,使用第一个页面`);
|
|
83
|
-
}
|
|
84
|
-
// 找到非 about:blank 的页面
|
|
85
|
-
for (const p of pages) {
|
|
86
|
-
if (!p.url().startsWith('about:') && !p.url().startsWith('devtools:')) return p;
|
|
87
|
-
}
|
|
88
|
-
return pages[0];
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ===== Action: list-pages =====
|
|
92
|
-
async function listPages(browser) {
|
|
93
|
-
const pages = await browser.pages();
|
|
94
|
-
const result = [];
|
|
95
|
-
for (let i = 0; i < pages.length; i++) {
|
|
96
|
-
const p = pages[i];
|
|
97
|
-
let title = '';
|
|
98
|
-
try { title = await p.title(); } catch {}
|
|
99
|
-
result.push({ index: i, url: p.url(), title });
|
|
100
|
-
log(`[${i}] ${p.url()}`);
|
|
101
|
-
if (title) log(` title: ${title}`);
|
|
102
|
-
}
|
|
103
|
-
return { pages: result, total: result.length };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// ===== Action: diagnose =====
|
|
107
|
-
async function diagnose(browser) {
|
|
108
|
-
ensureDir(OUTPUT_DIR);
|
|
109
|
-
const page = await findPage(browser);
|
|
110
|
-
log(`[PAGE] ${page.url()}`);
|
|
111
|
-
|
|
112
|
-
// 收集数据
|
|
113
|
-
const pageErrors = [];
|
|
114
|
-
const consoleMessages = [];
|
|
115
|
-
const failedRequests = [];
|
|
116
|
-
const apiRequests = [];
|
|
117
|
-
const resourceRequests = [];
|
|
118
|
-
|
|
119
|
-
page.on('pageerror', err => {
|
|
120
|
-
pageErrors.push({ message: err.message, stack: err.stack?.split('\n').slice(0, 5).join('\n') });
|
|
121
|
-
});
|
|
122
|
-
page.on('console', msg => {
|
|
123
|
-
const type = msg.type();
|
|
124
|
-
const text = msg.text().substring(0, 500);
|
|
125
|
-
consoleMessages.push({ type, text });
|
|
126
|
-
});
|
|
127
|
-
page.on('response', resp => {
|
|
128
|
-
const url = resp.url();
|
|
129
|
-
const status = resp.status();
|
|
130
|
-
const type = resp.request().resourceType();
|
|
131
|
-
if (type === 'xhr' || type === 'fetch') {
|
|
132
|
-
apiRequests.push({ status, url, method: resp.request().method() });
|
|
133
|
-
}
|
|
134
|
-
if (type === 'stylesheet' || type === 'script') {
|
|
135
|
-
resourceRequests.push({ status, url, type });
|
|
136
|
-
}
|
|
137
|
-
if (status >= 400) {
|
|
138
|
-
failedRequests.push({ status, url, type });
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
page.on('requestfailed', req => {
|
|
142
|
-
failedRequests.push({ status: 'ABORTED', url: req.url(), error: req.failure()?.errorText });
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
// 刷新页面收集
|
|
146
|
-
log('[INFO] 刷新页面收集诊断数据...');
|
|
147
|
-
try {
|
|
148
|
-
await page.reload({ waitUntil: 'load', timeout: 30000 });
|
|
149
|
-
} catch (e) {
|
|
150
|
-
log(`[WARN] 刷新超时: ${e.message.substring(0, 100)}`);
|
|
151
|
-
}
|
|
152
|
-
await new Promise(r => setTimeout(r, WAIT_MS));
|
|
153
|
-
|
|
154
|
-
// 截图
|
|
155
|
-
const screenshotPath = path.join(OUTPUT_DIR, 'diagnose-screenshot.png');
|
|
156
|
-
await page.screenshot({ path: screenshotPath, fullPage: false });
|
|
157
|
-
log(`[OK] 截图: ${screenshotPath}`);
|
|
158
|
-
|
|
159
|
-
// 检查 iframe
|
|
160
|
-
const frames = page.frames();
|
|
161
|
-
const iframeInfo = [];
|
|
162
|
-
for (const frame of frames) {
|
|
163
|
-
const url = frame.url();
|
|
164
|
-
if (url === page.url() || url === 'about:blank') continue;
|
|
165
|
-
let bodyText = '';
|
|
166
|
-
let rootContent = '';
|
|
167
|
-
try {
|
|
168
|
-
const info = await frame.evaluate(() => ({
|
|
169
|
-
bodyText: document.body?.innerText?.substring(0, 200) || '',
|
|
170
|
-
rootHTML: document.getElementById('root')?.innerHTML?.substring(0, 200) || 'no root',
|
|
171
|
-
hasReactRoot: !document.getElementById('root')?.innerHTML?.includes('loading-spinner'),
|
|
172
|
-
antdElements: document.querySelectorAll('[class*="ant-"]').length,
|
|
173
|
-
}));
|
|
174
|
-
bodyText = info.bodyText;
|
|
175
|
-
rootContent = info.rootHTML;
|
|
176
|
-
iframeInfo.push({ url, ...info });
|
|
177
|
-
} catch (e) {
|
|
178
|
-
iframeInfo.push({ url, error: e.message.substring(0, 100) });
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// 输出结果
|
|
183
|
-
const errors = consoleMessages.filter(m => m.type === 'error');
|
|
184
|
-
const warnings = consoleMessages.filter(m => m.type === 'warning');
|
|
185
|
-
|
|
186
|
-
const result = {
|
|
187
|
-
url: page.url(),
|
|
188
|
-
timestamp: new Date().toISOString(),
|
|
189
|
-
screenshot: screenshotPath,
|
|
190
|
-
pageErrors,
|
|
191
|
-
consoleErrors: errors,
|
|
192
|
-
consoleWarnings: warnings,
|
|
193
|
-
consoleTotal: consoleMessages.length,
|
|
194
|
-
failedRequests,
|
|
195
|
-
apiRequests,
|
|
196
|
-
resourceStatus: resourceRequests.map(r => ({
|
|
197
|
-
status: r.status, type: r.type, name: r.url.split('/').pop()
|
|
198
|
-
})),
|
|
199
|
-
iframes: iframeInfo,
|
|
200
|
-
summary: {
|
|
201
|
-
jsErrors: pageErrors.length,
|
|
202
|
-
consoleErrors: consoleMessages.filter(m => m.type === 'error').length,
|
|
203
|
-
failedNetwork: failedRequests.length,
|
|
204
|
-
totalAPI: apiRequests.length,
|
|
205
|
-
failedAPI: apiRequests.filter(r => r.status >= 400).length,
|
|
206
|
-
totalResources: resourceRequests.length,
|
|
207
|
-
failedResources: resourceRequests.filter(r => r.status >= 400).length,
|
|
208
|
-
}
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
// 打印摘要
|
|
212
|
-
log('\n========== 诊断结果 ==========');
|
|
213
|
-
log(`JS 异常: ${result.summary.jsErrors}`);
|
|
214
|
-
pageErrors.forEach(e => log(` [PAGEERROR] ${e.message}`));
|
|
215
|
-
log(`Console 错误: ${errors.length}`);
|
|
216
|
-
errors.forEach(e => log(` [ERROR] ${e.text}`));
|
|
217
|
-
log(`Console 警告: ${warnings.length}`);
|
|
218
|
-
warnings.forEach(e => log(` [WARN] ${e.text}`));
|
|
219
|
-
log(`Console 消息总数: ${consoleMessages.length}`);
|
|
220
|
-
log(`失败网络请求: ${result.summary.failedNetwork}`);
|
|
221
|
-
failedRequests.forEach(r => log(` [${r.status}] ${r.url}`));
|
|
222
|
-
log(`API 请求: ${result.summary.totalAPI} (失败: ${result.summary.failedAPI})`);
|
|
223
|
-
apiRequests.forEach(r => {
|
|
224
|
-
const marker = r.status >= 400 ? '❌' : '✅';
|
|
225
|
-
log(` ${marker} [${r.status}] ${r.method} ${r.url}`);
|
|
226
|
-
});
|
|
227
|
-
log(`CSS/JS 资源: ${result.summary.totalResources} (失败: ${result.summary.failedResources})`);
|
|
228
|
-
resourceRequests.filter(r => r.status >= 400).forEach(r => log(` ❌ [${r.status}] ${r.url}`));
|
|
229
|
-
if (iframeInfo.length > 0) {
|
|
230
|
-
log(`\niframe 信息:`);
|
|
231
|
-
iframeInfo.forEach(f => log(` ${f.url} → ${f.error || (f.hasReactRoot ? 'React已挂载' : '未挂载')}`));
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// 保存结果
|
|
235
|
-
if (OUTPUT) {
|
|
236
|
-
fs.writeFileSync(OUTPUT, JSON.stringify(result, null, 2));
|
|
237
|
-
log(`\n[OK] 结果已保存: ${OUTPUT}`);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return result;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ===== Action: screenshot =====
|
|
244
|
-
async function screenshot(browser) {
|
|
245
|
-
ensureDir(OUTPUT_DIR);
|
|
246
|
-
const page = await findPage(browser);
|
|
247
|
-
const filename = getArg('filename', 'screenshot.png');
|
|
248
|
-
const fullPath = path.join(OUTPUT_DIR, filename);
|
|
249
|
-
const fullPage = hasFlag('full-page');
|
|
250
|
-
await page.screenshot({ path: fullPath, fullPage });
|
|
251
|
-
log(`[OK] 截图已保存: ${fullPath}`);
|
|
252
|
-
return { filepath: fullPath };
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// ===== Action: api-check =====
|
|
256
|
-
async function apiCheck(browser) {
|
|
257
|
-
const page = await findPage(browser);
|
|
258
|
-
log(`[PAGE] ${page.url()}`);
|
|
259
|
-
|
|
260
|
-
const requests = [];
|
|
261
|
-
const failed = [];
|
|
262
|
-
|
|
263
|
-
page.on('response', resp => {
|
|
264
|
-
const type = resp.request().resourceType();
|
|
265
|
-
if (type === 'xhr' || type === 'fetch') {
|
|
266
|
-
requests.push({
|
|
267
|
-
status: resp.status(),
|
|
268
|
-
url: resp.url(),
|
|
269
|
-
method: resp.request().method(),
|
|
270
|
-
frameUrl: resp.request().frame()?.url?.() || '',
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
});
|
|
274
|
-
page.on('requestfailed', req => {
|
|
275
|
-
failed.push({ url: req.url(), error: req.failure()?.errorText });
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
log('[INFO] 刷新页面收集 API 请求...');
|
|
279
|
-
try { await page.reload({ waitUntil: 'load', timeout: 30000 }); } catch {}
|
|
280
|
-
await new Promise(r => setTimeout(r, WAIT_MS));
|
|
281
|
-
|
|
282
|
-
log('\n========== API 请求 ==========');
|
|
283
|
-
requests.forEach(r => {
|
|
284
|
-
const marker = r.status >= 400 ? '❌' : '✅';
|
|
285
|
-
const isIframe = r.frameUrl && r.frameUrl !== page.url() ? ' [iframe]' : '';
|
|
286
|
-
log(`${marker} [${r.status}] ${r.method} ${r.url}${isIframe}`);
|
|
287
|
-
});
|
|
288
|
-
if (failed.length > 0) {
|
|
289
|
-
log('\n========== 失败请求 ==========');
|
|
290
|
-
failed.forEach(r => log(`[FAILED] ${r.url} → ${r.error}`));
|
|
291
|
-
}
|
|
292
|
-
log(`\n总计: ${requests.length} 请求, 失败: ${requests.filter(r => r.status >= 400).length + failed.length}`);
|
|
293
|
-
|
|
294
|
-
return { requests, failed };
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// ===== Action: plan =====
|
|
298
|
-
async function executePlan(browser) {
|
|
299
|
-
const planPath = PLAN_FILE;
|
|
300
|
-
if (!planPath) { log('[ERROR] --plan-file 未指定'); return; }
|
|
301
|
-
const plan = JSON.parse(fs.readFileSync(planPath, 'utf-8'));
|
|
302
|
-
ensureDir(plan.options?.outputDir || OUTPUT_DIR);
|
|
303
|
-
|
|
304
|
-
log(`[PLAN] ${plan.description || plan.planId}`);
|
|
305
|
-
log(`[STEPS] ${plan.steps.length} 个步骤`);
|
|
306
|
-
|
|
307
|
-
const results = [];
|
|
308
|
-
let currentPage = await findPage(browser);
|
|
309
|
-
|
|
310
|
-
for (let i = 0; i < plan.steps.length; i++) {
|
|
311
|
-
const step = plan.steps[i];
|
|
312
|
-
const start = Date.now();
|
|
313
|
-
try {
|
|
314
|
-
let result = {};
|
|
315
|
-
switch (step.type) {
|
|
316
|
-
case 'navigate':
|
|
317
|
-
await currentPage.goto(step.url, { waitUntil: step.waitUntil || 'domcontentloaded', timeout: 30000 });
|
|
318
|
-
result = { url: currentPage.url() };
|
|
319
|
-
break;
|
|
320
|
-
case 'screenshot':
|
|
321
|
-
const fp = path.join(plan.options?.outputDir || OUTPUT_DIR, step.filename || `step-${i}.png`);
|
|
322
|
-
await currentPage.screenshot({ path: fp, fullPage: step.fullPage });
|
|
323
|
-
result = { filepath: fp };
|
|
324
|
-
break;
|
|
325
|
-
case 'waitFor':
|
|
326
|
-
if (step.selector) await currentPage.waitForSelector(step.selector, { timeout: step.timeout || 10000 });
|
|
327
|
-
result = { matched: true };
|
|
328
|
-
break;
|
|
329
|
-
case 'evaluate':
|
|
330
|
-
const evalResult = await currentPage.evaluate(step.expression);
|
|
331
|
-
result = { result: evalResult };
|
|
332
|
-
break;
|
|
333
|
-
case 'click':
|
|
334
|
-
await currentPage.click(step.selector);
|
|
335
|
-
result = { clicked: true };
|
|
336
|
-
break;
|
|
337
|
-
case 'checkElement':
|
|
338
|
-
const count = await currentPage.$$eval(step.selector, els => els.length).catch(() => 0);
|
|
339
|
-
const passed = step.expect?.minCount ? count >= step.expect.minCount : count > 0;
|
|
340
|
-
result = { passed, elementCount: count };
|
|
341
|
-
break;
|
|
342
|
-
default:
|
|
343
|
-
result = { skipped: true, reason: `unknown step type: ${step.type}` };
|
|
344
|
-
}
|
|
345
|
-
const duration = Date.now() - start;
|
|
346
|
-
results.push({ index: i, step: step.type, status: 'PASS', duration, ...result });
|
|
347
|
-
log(` [${i + 1}/${plan.steps.length}] ✅ ${step.type} (${duration}ms)`);
|
|
348
|
-
} catch (e) {
|
|
349
|
-
const duration = Date.now() - start;
|
|
350
|
-
results.push({ index: i, step: step.type, status: 'FAIL', duration, error: e.message });
|
|
351
|
-
log(` [${i + 1}/${plan.steps.length}] ❌ ${step.type} (${duration}ms) → ${e.message.substring(0, 100)}`);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const summary = {
|
|
356
|
-
total: results.length,
|
|
357
|
-
passed: results.filter(r => r.status === 'PASS').length,
|
|
358
|
-
failed: results.filter(r => r.status === 'FAIL').length,
|
|
359
|
-
};
|
|
360
|
-
log(`\n[RESULT] ${summary.passed}/${summary.total} 通过`);
|
|
361
|
-
|
|
362
|
-
if (OUTPUT) {
|
|
363
|
-
fs.writeFileSync(OUTPUT, JSON.stringify({ steps: results, summary }, null, 2));
|
|
364
|
-
}
|
|
365
|
-
return { steps: results, summary };
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// ===== 主入口 =====
|
|
369
|
-
(async () => {
|
|
370
|
-
// 检测 CDP
|
|
371
|
-
const cdpInfo = await checkCDP(CDP_URL);
|
|
372
|
-
if (!cdpInfo) {
|
|
373
|
-
log(`[ERROR] 无法连接到 CDP: ${CDP_URL}`);
|
|
374
|
-
log('请确保浏览器已启动调试端口:');
|
|
375
|
-
log(' Windows Edge: "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" --remote-debugging-port=9222');
|
|
376
|
-
log(' Windows Chrome: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222');
|
|
377
|
-
process.exit(1);
|
|
378
|
-
}
|
|
379
|
-
log(`[CDP] ${cdpInfo.Browser || 'connected'} (${CDP_URL})`);
|
|
380
|
-
|
|
381
|
-
const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: null });
|
|
382
|
-
|
|
383
|
-
let result;
|
|
384
|
-
switch (ACTION) {
|
|
385
|
-
case 'list-pages': result = await listPages(browser); break;
|
|
386
|
-
case 'diagnose': result = await diagnose(browser); break;
|
|
387
|
-
case 'screenshot': result = await screenshot(browser); break;
|
|
388
|
-
case 'api-check': result = await apiCheck(browser); break;
|
|
389
|
-
case 'plan': result = await executePlan(browser); break;
|
|
390
|
-
default: log(`[ERROR] 未知 action: ${ACTION}`);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
browser.disconnect();
|
|
394
|
-
log('\n[DONE]');
|
|
395
|
-
})();
|