@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.
@@ -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 | 点击元素 | `selector`, `waitForNavigation` | `{ clicked, tagName }` |
67
- | type | 输入文本 | `selector`, `text`, `clear` | `{ typed, value }` |
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 | 元素断言 | `selector`, `expect: {visible, minCount, maxCount, hasText}` | `{ passed, checks, elementCount }` |
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": "findFrame", "contentIncludes": "知识采编" }
145
- { "type": "click", "selector": "a.edit-btn" }
146
- { "type": "checkElement", "selector": "~.editKnowledge", "expect": { "minCount": 1 } }
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
- - `findFrame` 支持 `urlIncludes`、`titleIncludes`、`contentIncludes` 多种发现方式
150
- - 优先用 `contentIncludes`(按页面文字内容查找),不依赖 URL/端口
151
- - `findFrame` 后,后续所有步骤自动在 iframe 内执行
152
- - iframe 内的 click 使用 `evaluate()` 触发 DOM click,不依赖坐标
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
- 浏览器测试依赖(puppeteer-core, pixelmatch, pngjs, sharp)采用**共享缓存**方案:
272
- - 首次使用时自动安装到 `~/.szcd-mcp/deps/`
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 puppeteer-core pixelmatch pngjs sharp --registry=https://registry.npmmirror.com
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
+ })();