@szc-ft/mcp-szcd-client 0.27.1 → 0.27.3
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/commands/szcd-mcp-browser-test.md +1 -1
- package/lib/browser-engine.js +831 -5
- package/lib/shared-deps.js +80 -40
- package/lib/visual-compare.js +3 -3
- package/local-browser-executor.js +321 -7
- package/opencode-extension/commands/szcd-mcp-browser-test.md +1 -1
- package/opencode-extension/skills/local-browser-test/SKILL.md +199 -15
- package/opencode-extension/skills/local-browser-test/local-browser-executor-old.cjs +395 -0
- package/opencode-extension/skills/local-browser-test/local-browser-executor.cjs +348 -16
- package/package.json +1 -1
- package/qwen-extension/commands/szcd-mcp-browser-test.md +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +199 -15
- package/qwen-extension/skills/local-browser-test/local-browser-executor-old.cjs +395 -0
- package/qwen-extension/skills/local-browser-test/local-browser-executor.cjs +348 -16
- package/standard-skill/local-browser-test/SKILL.md +199 -15
- package/standard-skill/local-browser-test/local-browser-executor.cjs +348 -16
|
@@ -17,6 +17,24 @@ compatibility:
|
|
|
17
17
|
- "功能测试"、"能不能正常用"
|
|
18
18
|
- "检查页面"、"页面渲染"
|
|
19
19
|
|
|
20
|
+
## 主链路定位
|
|
21
|
+
|
|
22
|
+
local-browser-test 的长期主链路是:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
Agent / SKILL.md
|
|
26
|
+
→ szcd-mcp-client/local-browser-executor.js
|
|
27
|
+
→ lib/browser-engine.js
|
|
28
|
+
→ puppeteer-core + CDP
|
|
29
|
+
→ 用户真实 Chrome / Edge
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
目标是在用户真实登录态/真实微前端环境中形成 browser-use + Playwright 结合体:
|
|
33
|
+
- browser-use 侧:`observe → act → observe`,通过结构化页面观察和交互元素 index 支持自主任务执行。
|
|
34
|
+
- Playwright 侧:locator、actionability、assert、plan 报告逐步收敛到 `BrowserEngine`。
|
|
35
|
+
|
|
36
|
+
`standard-skill/local-browser-test/local-browser-executor.cjs` 是过渡兼容执行器;新增稳定能力优先进入 `szcd-mcp-client/lib/browser-engine.js` 和根目录 `local-browser-executor.js`。
|
|
37
|
+
|
|
20
38
|
## 浏览器模式
|
|
21
39
|
|
|
22
40
|
支持两种模式,执行器自动检测优先使用 connect:
|
|
@@ -60,14 +78,19 @@ AI 直接构造测试计划 JSON:
|
|
|
60
78
|
|
|
61
79
|
| type | 功能 | 参数 | 返回 |
|
|
62
80
|
|------|------|------|------|
|
|
81
|
+
| observe | 结构化观察 | `selector`, `depth`, `frameContentContains`, `maxInteractiveElements` | `{ frames, tree, interactiveElements[] }` |
|
|
82
|
+
| act | 按 index/selector/menuPath 交互 | `index` 或 `selector/click/hover` 或 `menuPath`, `typeText`, `clear`, `frameContentContains` | `{ acted, action, element/steps }` |
|
|
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 }` |
|
|
63
86
|
| navigate | 导航到 URL | `url`, `waitUntil` | `{ url, title, loadTime }` |
|
|
64
87
|
| screenshot | 页面截图 | `fullPage`, `clip`, `filename` | `{ filepath, width, height }` |
|
|
65
88
|
| compare | 设计稿对比 | `designImagePath`, `threshold`, `regions[]` | `{ fidelity, diffPixels, diffImagePath, regions[] }` |
|
|
66
|
-
| click |
|
|
67
|
-
| type |
|
|
89
|
+
| click | 点击元素(兼容) | `selector`, `waitForNavigation` | `{ clicked, tagName }` |
|
|
90
|
+
| type | 输入文本(兼容) | `selector`, `text`, `clear` | `{ typed, value }` |
|
|
68
91
|
| waitFor | 等待条件 | `selector` 或 `url`, `timeout` | `{ matched, waited }` |
|
|
69
92
|
| evaluate | 执行 JS | `expression` | `{ result }` |
|
|
70
|
-
| checkElement |
|
|
93
|
+
| checkElement | 元素断言(兼容) | `selector`, `expect: {visible, minCount, maxCount, hasText}` | `{ passed, checks, elementCount }` |
|
|
71
94
|
| findPage | 查找已打开的标签页 | `urlIncludes` 或 `urlRegex` 或 `titleIncludes` | `{ found, url, title, totalPages }` |
|
|
72
95
|
| findFrame | 查找微前端 iframe | `urlIncludes` 或 `urlRegex` 或 `titleIncludes` 或 `contentIncludes` 或 `frameIndex` | `{ found, frameUrl, matchedBy, totalFrames }` |
|
|
73
96
|
| loginWait | 等待 SSO 登录完成 | `loginSelector`, `timeout`, `interval` | `{ loggedIn, elapsed, currentUrl }` |
|
|
@@ -141,15 +164,15 @@ AI 直接构造测试计划 JSON:
|
|
|
141
164
|
wujie 微前端子应用渲染在 blob URL iframe 中,**坐标点击无法到达 iframe 内容**。
|
|
142
165
|
|
|
143
166
|
```json
|
|
144
|
-
{ "type": "
|
|
145
|
-
{ "type": "
|
|
146
|
-
{ "type": "
|
|
167
|
+
{ "type": "observe", "frameContentContains": "知识采编", "depth": 5, "maxInteractiveElements": 80 }
|
|
168
|
+
{ "type": "act", "index": 3, "frameContentContains": "知识采编" }
|
|
169
|
+
{ "type": "assert", "selector": "~.editKnowledge", "expect": { "minCount": 1 } }
|
|
147
170
|
```
|
|
148
171
|
|
|
149
|
-
- `
|
|
150
|
-
-
|
|
151
|
-
- `
|
|
152
|
-
- iframe 内的
|
|
172
|
+
- 优先用 `observe` 发现 iframe 内的 `interactiveElements[index]`,再用 `act.index` 操作,不要重新猜 selector
|
|
173
|
+
- `observe/act` 支持 `frameUrlContains`、`frameContentContains`、`frameIndex` 多种发现方式
|
|
174
|
+
- 优先用 `frameContentContains`(按页面文字内容查找),不依赖 URL/端口
|
|
175
|
+
- iframe 内的 act 使用 DOM 事件触发,不依赖坐标
|
|
153
176
|
|
|
154
177
|
### CSS Modules 哈希类名
|
|
155
178
|
项目使用 CSS Modules,类名会被哈希化(如 `editKnowledge___ynQgI`)。
|
|
@@ -192,7 +215,81 @@ wujie 微前端子应用渲染在 blob URL iframe 中,**坐标点击无法到
|
|
|
192
215
|
|
|
193
216
|
## 执行命令
|
|
194
217
|
|
|
195
|
-
###
|
|
218
|
+
### 结构化观察(主链路起点)
|
|
219
|
+
```bash
|
|
220
|
+
node {client_path}/local-browser-executor.js \
|
|
221
|
+
--action observe \
|
|
222
|
+
--url-contains "platform.aicityos.com" \
|
|
223
|
+
--frame-content-contains "数据集目录" \
|
|
224
|
+
--depth 5 \
|
|
225
|
+
--output /tmp/browser-observe.json
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`observe` 输出 `frames`、`tree`、`interactiveElements[index]`、`selectorCandidates`、`bbox`、`state`。Agent 后续应优先使用 `index` 调用 `act`,不要从 DOM 树重新猜 selector。直接 `--action observe` 默认只连接用户已启动的真实浏览器 CDP;如确需无 CDP 时启动 headless 回退,追加 `--allow-launch`。
|
|
229
|
+
|
|
230
|
+
### 观察后按 index 交互(主链路闭环)
|
|
231
|
+
```bash
|
|
232
|
+
node {client_path}/local-browser-executor.js \
|
|
233
|
+
--action act \
|
|
234
|
+
--url-contains "platform.aicityos.com" \
|
|
235
|
+
--frame-content-contains "数据集目录" \
|
|
236
|
+
--index 3 \
|
|
237
|
+
--observe-after \
|
|
238
|
+
--output /tmp/browser-act-observe.json
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### 左侧菜单路径稳定点击
|
|
242
|
+
```bash
|
|
243
|
+
node {client_path}/local-browser-executor.js \
|
|
244
|
+
--action act \
|
|
245
|
+
--url-contains "platform.aicityos.com" \
|
|
246
|
+
--menu-path "目录管理/数据集目录" \
|
|
247
|
+
--observe-after \
|
|
248
|
+
--output /tmp/browser-menu-act.json
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`--menu-path` 会按路径逐级匹配菜单项,非末级优先执行展开,末级执行点击;匹配优先考虑 `role=menuitem`、`.ant-menu-item`、`.ant-menu-submenu-title` 和文本精确/包含匹配。
|
|
252
|
+
|
|
253
|
+
### 轻量 DOM 断言
|
|
254
|
+
```bash
|
|
255
|
+
node {client_path}/local-browser-executor.js \
|
|
256
|
+
--action assert \
|
|
257
|
+
--url-contains "platform.aicityos.com" \
|
|
258
|
+
--selector ".ant-table" \
|
|
259
|
+
--expect-visible \
|
|
260
|
+
--min-count 1 \
|
|
261
|
+
--output /tmp/browser-assert.json
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### API 请求/响应捕获(真实浏览器 + 微前端)
|
|
265
|
+
```bash
|
|
266
|
+
node {client_path}/local-browser-executor.js \
|
|
267
|
+
--action api-capture \
|
|
268
|
+
--url-contains "platform.aicityos.com" \
|
|
269
|
+
--frame-content-contains "数据集目录" \
|
|
270
|
+
--url-pattern "/api/" \
|
|
271
|
+
--wait 15000 \
|
|
272
|
+
--include-response-body \
|
|
273
|
+
--reload \
|
|
274
|
+
--output /tmp/browser-api-capture.json
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`api-capture` 使用 CDP `Network.*` 事件监听主页面和可 attach 的 page/iframe target,不使用 `setRequestInterception`,避免阻断真实请求。wujie/qiankun 场景优先传 `--frame-content-contains` 锁定业务 iframe;如需监听全部 target,追加 `--include-all-targets`。
|
|
278
|
+
|
|
279
|
+
### API 捕获结果断言
|
|
280
|
+
```bash
|
|
281
|
+
node {client_path}/local-browser-executor.js \
|
|
282
|
+
--action assert-api \
|
|
283
|
+
--capture-file /tmp/browser-api-capture.json \
|
|
284
|
+
--max-failed 0 \
|
|
285
|
+
--forbid-status-gte 400 \
|
|
286
|
+
--required-url "/list" \
|
|
287
|
+
--output /tmp/browser-api-assert.json
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
计划内可直接在 `apiCapture` 后追加 `{ "type": "assertApi", "maxFailed": 0, "forbidStatusGte": 400 }`,会自动读取上一步捕获结果。
|
|
291
|
+
|
|
292
|
+
### 快速诊断(过渡兼容)
|
|
196
293
|
```bash
|
|
197
294
|
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
198
295
|
--action diagnose \
|
|
@@ -232,16 +329,98 @@ NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor
|
|
|
232
329
|
--output /tmp/browser-test-result.json
|
|
233
330
|
```
|
|
234
331
|
|
|
332
|
+
### DOM 快照(结构化页面树,类似 browser-use take_snapshot)
|
|
333
|
+
```bash
|
|
334
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
335
|
+
--action snapshot \
|
|
336
|
+
--url-contains "platform.aicityos.com" \
|
|
337
|
+
--depth 4 \
|
|
338
|
+
--selector ".page-container"
|
|
339
|
+
```
|
|
340
|
+
输出示例:
|
|
341
|
+
```
|
|
342
|
+
<div.page-container>
|
|
343
|
+
<section.page-layout>
|
|
344
|
+
<header.page-header>
|
|
345
|
+
<div.page-header-left> <div.logo>
|
|
346
|
+
<section.h-box>
|
|
347
|
+
<aside.ant-layout-sider> 🔗
|
|
348
|
+
<main.ant-layout-content>
|
|
349
|
+
<div.layout-wrap>
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### 智能交互(支持 text=、role=、~ 选择器,类似 Playwright)
|
|
353
|
+
```bash
|
|
354
|
+
# 点击包含“构建”文本的按钮
|
|
355
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
356
|
+
--action interact \
|
|
357
|
+
--url-contains "jenkins" \
|
|
358
|
+
--click "text=Build"
|
|
359
|
+
|
|
360
|
+
# 在输入框中输入文本
|
|
361
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
362
|
+
--action interact \
|
|
363
|
+
--click "#search-input" \
|
|
364
|
+
--type "hello world"
|
|
365
|
+
|
|
366
|
+
# 悬停 + 截图
|
|
367
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
368
|
+
--action interact \
|
|
369
|
+
--hover "~ant-menu-item" \
|
|
370
|
+
--screenshot-after
|
|
371
|
+
|
|
372
|
+
# 滚动页面
|
|
373
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
374
|
+
--action interact \
|
|
375
|
+
--scroll down --scroll-amount 500
|
|
376
|
+
```
|
|
377
|
+
选择器语法:
|
|
378
|
+
- `text=Build` → 按文本内容查找(类似 Playwright text selector)
|
|
379
|
+
- `role=button` → 按 ARIA role 查找
|
|
380
|
+
- `~ant-menu-item` → CSS class 模糊匹配(`[class*="ant-menu-item"]`)
|
|
381
|
+
- `.my-class` → 精确 CSS 选择器
|
|
382
|
+
|
|
383
|
+
### 性能指标采集(FCP/TTFB/资源统计)
|
|
384
|
+
```bash
|
|
385
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
386
|
+
--action perf \
|
|
387
|
+
--url-contains "platform.aicityos.com"
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### 持续监控模式(定时截图 + 错误检测)
|
|
391
|
+
```bash
|
|
392
|
+
NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
|
|
393
|
+
--action watch \
|
|
394
|
+
--url-contains "platform.aicityos.com" \
|
|
395
|
+
--interval 5000 \
|
|
396
|
+
--max-rounds 60 \
|
|
397
|
+
--output-dir /tmp/browser-watch
|
|
398
|
+
```
|
|
399
|
+
|
|
235
400
|
### 可选参数
|
|
236
401
|
|
|
237
402
|
- `--cdp-url http://localhost:9222`:CDP 地址(默认 9222)
|
|
238
403
|
- `--url <url>`:目标页面 URL(不存在则导航)
|
|
239
404
|
- `--url-contains <keyword>`:按关键词查找已打开的页面
|
|
405
|
+
- `--frame-url-contains <keyword>`:observe 时按 iframe URL 片段匹配目标 frame
|
|
406
|
+
- `--frame-content-contains <keyword>`:observe 时按 iframe 文本内容匹配目标 frame
|
|
407
|
+
- `--frame-index <n>`:observe 时按 frame 下标选择目标 frame
|
|
240
408
|
- `--wait <ms>`:额外等待时间(默认 5000ms,微前端建议 15000)
|
|
241
409
|
- `--output <path>`:JSON 结果输出路径
|
|
242
410
|
- `--output-dir <dir>`:截图/产物目录
|
|
243
411
|
- `--filename <name>`:截图文件名
|
|
244
412
|
- `--full-page`:全页截图
|
|
413
|
+
- `--selector <css>`:快照/observe 根元素(默认 body)
|
|
414
|
+
- `--depth <n>`:DOM 深度(默认 5)
|
|
415
|
+
- `--click <selector>`:点击元素(支持 text=/role=/~)
|
|
416
|
+
- `--type <text>`:输入文本
|
|
417
|
+
- `--hover <selector>`:悬停元素
|
|
418
|
+
- `--scroll <up|down|left|right>`:滚动方向
|
|
419
|
+
- `--scroll-amount <px>`:滚动距离(默认 300)
|
|
420
|
+
- `--screenshot-after`:交互后自动截图
|
|
421
|
+
- `--interval <ms>`:watch 模式间隔(默认 5000)
|
|
422
|
+
- `--max-rounds <n>`:watch 最大轮数(默认 60)
|
|
423
|
+
- `--allow-launch`:根执行器 action 模式允许无 CDP 时启动 headless Chrome;默认不启用,优先保障真实用户浏览器链路
|
|
245
424
|
|
|
246
425
|
## AI 语义断言(aiAssert)
|
|
247
426
|
|
|
@@ -268,12 +447,17 @@ NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor
|
|
|
268
447
|
|
|
269
448
|
## 依赖管理
|
|
270
449
|
|
|
271
|
-
|
|
272
|
-
-
|
|
450
|
+
浏览器测试依赖采用**共享缓存 + 按需安装**方案:
|
|
451
|
+
- `observe/act/assert/diagnose/screenshot` 主链路只安装 `puppeteer-core` 到 `~/.szcd-mcp/deps/`
|
|
452
|
+
- 只有执行设计稿像素对比 `compare` 时才安装 `pixelmatch/pngjs/sharp`
|
|
273
453
|
- 后续所有项目共享复用,无需重复安装
|
|
274
|
-
-
|
|
454
|
+
- 如果核心依赖自动安装失败,引导用户手动执行:
|
|
455
|
+
```bash
|
|
456
|
+
cd ~/.szcd-mcp/deps && npm install --registry=https://registry.npmmirror.com puppeteer-core
|
|
457
|
+
```
|
|
458
|
+
- 如果视觉对比依赖自动安装失败,引导用户手动执行:
|
|
275
459
|
```bash
|
|
276
|
-
cd ~/.szcd-mcp/deps && npm install
|
|
460
|
+
cd ~/.szcd-mcp/deps && npm install --registry=https://registry.npmmirror.com --@img:registry=https://registry.npmmirror.com --prefer-offline --no-audit --no-fund --omit=dev --package-lock=false --sharp_binary_host=https://npmmirror.com/mirrors/sharp --sharp_libvips_binary_host=https://npmmirror.com/mirrors/sharp-libvips pixelmatch pngjs sharp
|
|
277
461
|
```
|
|
278
462
|
|
|
279
463
|
## 结果解读
|
|
@@ -0,0 +1,395 @@
|
|
|
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
|
+
})();
|