@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.
@@ -1,24 +1,41 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * local-browser-executor.js - 本地浏览器自动化执行器
3
+ * local-browser-executor.cjs - 本地浏览器自动化执行器
4
4
  *
5
5
  * 功能:
6
- * --action diagnose 全面诊断(JS错误、网络请求、API检查、资源加载)
7
- * --action list-pages 列出所有标签页
8
- * --action screenshot 截图
9
- * --action api-check 检查 API 请求状态
10
- * --action plan 执行测试计划 JSON
6
+ * --action diagnose 全面诊断(JS错误、网络请求、API检查、资源加载)
7
+ * --action list-pages 列出所有标签页
8
+ * --action screenshot 截图(支持 --full-page 滚动截图)
9
+ * --action api-check 检查 API 请求状态
10
+ * --action snapshot 结构化 DOM 快照(类似 browser-use take_snapshot)
11
+ * --action interact 智能交互(支持 text=/role= 选择器,类似 Playwright)
12
+ * --action perf 性能指标采集(FCP/LCP/CLS/TBT)
13
+ * --action watch 持续监控模式(定时截图+错误检测)
14
+ * --action plan 执行测试计划 JSON
11
15
  *
12
16
  * 连接方式:
13
- * --cdp-url http://localhost:9222 (默认自动检测 9222)
14
- * --auto-detect 自动检测 Chrome/Edge 进程
17
+ * --cdp-url http://localhost:9222 (默认 9222)
15
18
  *
16
19
  * 通用参数:
17
- * --url <url> 目标页面 URL
18
- * --url-contains <s> 按关键词查找已打开的页面
19
- * --wait <ms> 额外等待时间(默认 5000)
20
- * --output <path> 结果输出文件
21
- * --output-dir <dir> 截图/产物输出目录
20
+ * --url <url> 目标页面 URL
21
+ * --url-contains <s> 按关键词查找已打开的页面
22
+ * --wait <ms> 额外等待时间(默认 5000)
23
+ * --output <path> 结果输出文件
24
+ * --output-dir <dir> 截图/产物输出目录
25
+ *
26
+ * snapshot 参数:
27
+ * --selector <css> 快照根元素(默认 body)
28
+ * --depth <n> DOM 深度(默认 5)
29
+ *
30
+ * interact 参数:
31
+ * --click <selector> 点击元素(支持 text=、role=、CSS)
32
+ * --type <text> 输入文本(配合 --click 定位输入框)
33
+ * --hover <selector> 悬停元素
34
+ * --scroll <direction> 滚动(up/down/left/right,配合 --scroll-amount)
35
+ *
36
+ * watch 参数:
37
+ * --interval <ms> 监控间隔(默认 5000)
38
+ * --max-rounds <n> 最大轮数(默认 60)
22
39
  */
23
40
 
24
41
  const puppeteer = require('puppeteer-core');
@@ -65,7 +82,41 @@ function checkCDP(url) {
65
82
  }
66
83
 
67
84
  async function findPage(browser) {
68
- const pages = await browser.pages();
85
+ let pages;
86
+ try {
87
+ pages = await Promise.race([
88
+ browser.pages(),
89
+ new Promise((_, reject) => setTimeout(() => reject(new Error('pages() timeout')), 8000)),
90
+ ]);
91
+ } catch {
92
+ // browser.pages() 超时,尝试用 targets() 找到目标再 .page()
93
+ try {
94
+ const targets = browser.targets();
95
+ let matchTarget = null;
96
+ if (URL_CONTAINS) {
97
+ matchTarget = targets.find(t => t.type() === 'page' && t.url().includes(URL_CONTAINS));
98
+ }
99
+ if (TARGET_URL && !matchTarget) {
100
+ matchTarget = targets.find(t => t.type() === 'page' && t.url().includes(TARGET_URL));
101
+ }
102
+ if (!matchTarget) {
103
+ matchTarget = targets.find(t => t.type() === 'page' && !t.url().startsWith('about:') && !t.url().startsWith('devtools:'));
104
+ }
105
+ if (matchTarget) {
106
+ const page = await matchTarget.page();
107
+ if (page) return page;
108
+ }
109
+ } catch (e) {
110
+ log(`[WARN] targets() 回退失败: ${e.message}`);
111
+ }
112
+ // 最终回退
113
+ if (TARGET_URL) {
114
+ const page = await browser.newPage();
115
+ await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
116
+ return page;
117
+ }
118
+ return await browser.newPage();
119
+ }
69
120
  if (TARGET_URL) {
70
121
  for (const p of pages) {
71
122
  if (p.url().includes(TARGET_URL) || p.url() === TARGET_URL) return p;
@@ -90,7 +141,27 @@ async function findPage(browser) {
90
141
 
91
142
  // ===== Action: list-pages =====
92
143
  async function listPages(browser) {
93
- const pages = await browser.pages();
144
+ log('[INFO] 获取标签页列表...');
145
+ // browser.pages() 在大量标签页时可能卡住,用 CDP 直接获取
146
+ let pages;
147
+ try {
148
+ pages = await Promise.race([
149
+ browser.pages(),
150
+ new Promise((_, reject) => setTimeout(() => reject(new Error('pages() timeout')), 5000)),
151
+ ]);
152
+ } catch {
153
+ // 回退:通过 CDP Target API 获取
154
+ const client = await browser.target().createCDPSession();
155
+ const { targetInfos } = await client.send('Target.getTargets');
156
+ log(`[INFO] CDP 回退: 共 ${targetInfos.filter(t => t.type === 'page').length} 个标签页`);
157
+ for (const t of targetInfos.filter(t => t.type === 'page')) {
158
+ log(` ${t.url}`);
159
+ if (t.title) log(` title: ${t.title}`);
160
+ }
161
+ try { await client.detach(); } catch {}
162
+ return { total: targetInfos.filter(t => t.type === 'page').length };
163
+ }
164
+ log(`[INFO] 共 ${pages.length} 个标签页`);
94
165
  const result = [];
95
166
  for (let i = 0; i < pages.length; i++) {
96
167
  const p = pages[i];
@@ -365,6 +436,246 @@ async function executePlan(browser) {
365
436
  return { steps: results, summary };
366
437
  }
367
438
 
439
+ // ===== 智能选择器解析(融合 Playwright 风格) =====
440
+ async function smartResolve(pageOrFrame, selector) {
441
+ if (!selector) return null;
442
+ if (selector.startsWith('text=')) {
443
+ const text = selector.slice(5).replace(/^["']|["']$/g, '');
444
+ return pageOrFrame.evaluateHandle(t => {
445
+ const all = document.querySelectorAll('a, button, [role="button"], input, [class*="btn"], span, div, p');
446
+ return Array.from(all).find(el => el.textContent?.includes(t) && el.offsetHeight > 0) || null;
447
+ }, text);
448
+ }
449
+ if (selector.startsWith('role=')) {
450
+ return pageOrFrame.$(`[role="${selector.slice(5)}"]`);
451
+ }
452
+ if (selector.startsWith('~')) {
453
+ return pageOrFrame.$(`[class*="${selector.slice(1).replace(/^\./, '')}"]`);
454
+ }
455
+ return pageOrFrame.$(selector);
456
+ }
457
+
458
+ // ===== Action: snapshot (结构化 DOM 快照) =====
459
+ async function snapshot(browser) {
460
+ const page = await findPage(browser);
461
+ const selector = getArg('selector', 'body');
462
+ const maxDepth = parseInt(getArg('depth', '5'), 10);
463
+ log(`[PAGE] ${page.url()}`);
464
+
465
+ const tree = await page.evaluate((rootSel, depth) => {
466
+ function buildTree(el, d) {
467
+ if (d > depth || !el) return null;
468
+ const tag = el.tagName?.toLowerCase();
469
+ if (!tag || ['script','style','svg','path'].includes(tag)) return null;
470
+ const attrs = {};
471
+ for (const a of (el.attributes || [])) {
472
+ if (['id','class','href','src','role','aria-label','data-testid'].includes(a.name)) {
473
+ attrs[a.name] = a.value.substring(0, 100);
474
+ }
475
+ }
476
+ // 直接文本内容
477
+ let text = '';
478
+ for (const node of el.childNodes) {
479
+ if (node.nodeType === 3) text += node.textContent.trim();
480
+ }
481
+ // 子元素
482
+ const children = [];
483
+ for (const child of el.children) {
484
+ const childTree = buildTree(child, d + 1);
485
+ if (childTree) children.push(childTree);
486
+ }
487
+ // 可交互性
488
+ const interactive = ['a','button','input','select','textarea'].includes(tag) ||
489
+ el.getAttribute('role') === 'button' || el.onclick || el.style.cursor === 'pointer';
490
+ // 可见性
491
+ const rect = el.getBoundingClientRect();
492
+ const visible = rect.width > 0 && rect.height > 0;
493
+ return { tag, attrs, text: text.substring(0, 80), children, interactive, visible, depth: d };
494
+ }
495
+ const root = document.querySelector(rootSel) || document.body;
496
+ return buildTree(root, 0);
497
+ }, selector, maxDepth);
498
+
499
+ // 打印树形结构
500
+ function printTree(node, indent = '') {
501
+ if (!node) return;
502
+ const cls = node.attrs.class ? `.${node.attrs.class.split(' ')[0].substring(0, 30)}` : '';
503
+ const id = node.attrs.id ? `#${node.attrs.id}` : '';
504
+ const role = node.attrs.role ? `[role=${node.attrs.role}]` : '';
505
+ const interactive = node.interactive ? ' \u{1F517}' : '';
506
+ const vis = node.visible ? '' : ' [hidden]';
507
+ const txt = node.text ? ` "${node.text.substring(0, 40)}"` : '';
508
+ log(`${indent}<${node.tag}${id}${cls}${role}>${txt}${interactive}${vis}`);
509
+ for (const child of (node.children || [])) {
510
+ printTree(child, indent + ' ');
511
+ }
512
+ }
513
+ log('\n========== DOM 快照 ==========');
514
+ printTree(tree);
515
+
516
+ if (OUTPUT) {
517
+ fs.writeFileSync(OUTPUT, JSON.stringify(tree, null, 2));
518
+ log(`\n[OK] 结果已保存: ${OUTPUT}`);
519
+ }
520
+ return tree;
521
+ }
522
+
523
+ // ===== Action: interact (智能交互) =====
524
+ async function interact(browser) {
525
+ const page = await findPage(browser);
526
+ log(`[PAGE] ${page.url()}`);
527
+
528
+ const clickSel = getArg('click', null);
529
+ const typeText = getArg('type', null);
530
+ const hoverSel = getArg('hover', null);
531
+ const scrollDir = getArg('scroll', null);
532
+ const scrollAmt = parseInt(getArg('scroll-amount', '300'), 10);
533
+
534
+ if (clickSel) {
535
+ const el = await smartResolve(page, clickSel);
536
+ if (el) {
537
+ await el.click();
538
+ log(`[OK] 点击: ${clickSel}`);
539
+ await new Promise(r => setTimeout(r, 1000));
540
+ } else {
541
+ log(`[FAIL] 未找到元素: ${clickSel}`);
542
+ }
543
+ }
544
+ if (typeText) {
545
+ const target = clickSel ? await smartResolve(page, clickSel) : await page.$('input:focus, textarea:focus');
546
+ if (target) {
547
+ await target.type(typeText, { delay: 50 });
548
+ log(`[OK] 输入: "${typeText}"`);
549
+ } else {
550
+ log(`[FAIL] 未找到输入目标`);
551
+ }
552
+ }
553
+ if (hoverSel) {
554
+ const el = await smartResolve(page, hoverSel);
555
+ if (el) {
556
+ await el.hover();
557
+ log(`[OK] 悬停: ${hoverSel}`);
558
+ }
559
+ }
560
+ if (scrollDir) {
561
+ const dx = scrollDir === 'left' ? -scrollAmt : scrollDir === 'right' ? scrollAmt : 0;
562
+ const dy = scrollDir === 'up' ? -scrollAmt : scrollDir === 'down' ? scrollAmt : 0;
563
+ await page.evaluate(({ dx, dy }) => window.scrollBy(dx, dy), { dx, dy });
564
+ log(`[OK] 滚动: ${scrollDir} ${scrollAmt}px`);
565
+ }
566
+
567
+ // 交互后截图
568
+ if (hasFlag('screenshot-after')) {
569
+ ensureDir(OUTPUT_DIR);
570
+ const fp = path.join(OUTPUT_DIR, 'after-interact.png');
571
+ await page.screenshot({ path: fp });
572
+ log(`[OK] 截图: ${fp}`);
573
+ }
574
+ }
575
+
576
+ // ===== Action: perf (性能指标) =====
577
+ async function perf(browser) {
578
+ const page = await findPage(browser);
579
+ log(`[PAGE] ${page.url()}`);
580
+
581
+ // 收集性能指标
582
+ const perfMetrics = await page.evaluate(() => {
583
+ const perf = performance.getEntriesByType('navigation')[0];
584
+ const paint = performance.getEntriesByType('paint');
585
+ const fcp = paint.find(p => p.name === 'first-contentful-paint');
586
+ // LCP 通过 PerformanceObserver 获取
587
+ const resources = performance.getEntriesByType('resource');
588
+ return {
589
+ // 导航时间
590
+ dns: perf?.domainEnd - perf?.domainStart || 0,
591
+ tcp: perf?.connectEnd - perf?.connectStart || 0,
592
+ ttfb: perf?.responseStart - perf?.requestStart || 0,
593
+ domReady: perf?.domContentLoadedEventEnd - perf?.startTime || 0,
594
+ loadComplete: perf?.loadEventEnd - perf?.startTime || 0,
595
+ // 绘制
596
+ fcp: fcp?.startTime || null,
597
+ // 资源统计
598
+ totalResources: resources.length,
599
+ totalTransferSize: resources.reduce((sum, r) => sum + (r.transferSize || 0), 0),
600
+ slowResources: resources.filter(r => r.duration > 1000).map(r => ({
601
+ name: r.name.split('/').pop(), duration: Math.round(r.duration), size: r.transferSize
602
+ })),
603
+ };
604
+ });
605
+
606
+ // Web Vitals (通过 CDP)
607
+ let webVitals = {};
608
+ try {
609
+ const client = await page.target().createCDPSession();
610
+ await client.send('Performance.enable');
611
+ const metrics = await client.send('Performance.getMetrics');
612
+ const m = {};
613
+ metrics.metrics.forEach(item => { m[item.name] = item.value; });
614
+ webVitals = {
615
+ layoutCount: m.LayoutCount || 0,
616
+ recalcCount: m.RecalcLayoutCount || 0,
617
+ jsHeapUsed: Math.round((m.JSHeapUsedSize || 0) / 1024 / 1024) + 'MB',
618
+ jsHeapTotal: Math.round((m.JSHeapTotalSize || 0) / 1024 / 1024) + 'MB',
619
+ taskDuration: (m.TaskDuration || 0).toFixed(2) + 's',
620
+ };
621
+ await client.send('Performance.disable');
622
+ client.detach();
623
+ } catch {}
624
+
625
+ log('\n========== 性能指标 ==========');
626
+ log(`DNS: ${Math.round(perfMetrics.dns)}ms`);
627
+ log(`TCP: ${Math.round(perfMetrics.tcp)}ms`);
628
+ log(`TTFB: ${Math.round(perfMetrics.ttfb)}ms`);
629
+ log(`DOM Ready: ${Math.round(perfMetrics.domReady)}ms`);
630
+ log(`Load Complete: ${Math.round(perfMetrics.loadComplete)}ms`);
631
+ log(`FCP: ${perfMetrics.fcp ? Math.round(perfMetrics.fcp) + 'ms' : 'N/A'}`);
632
+ log(`\n资源总数: ${perfMetrics.totalResources}`);
633
+ log(`传输总量: ${Math.round(perfMetrics.totalTransferSize / 1024)}KB`);
634
+ if (perfMetrics.slowResources.length > 0) {
635
+ log(`\n慢资源 (>1s):`);
636
+ perfMetrics.slowResources.forEach(r => log(` ${r.duration}ms ${r.name} (${Math.round((r.size||0)/1024)}KB)`));
637
+ }
638
+ if (Object.keys(webVitals).length > 0) {
639
+ log('\n========== Chrome 内部指标 ==========');
640
+ Object.entries(webVitals).forEach(([k, v]) => log(` ${k}: ${v}`));
641
+ }
642
+
643
+ return { perfMetrics, webVitals };
644
+ }
645
+
646
+ // ===== Action: watch (持续监控) =====
647
+ async function watch(browser) {
648
+ const page = await findPage(browser);
649
+ const interval = parseInt(getArg('interval', '5000'), 10);
650
+ const maxRounds = parseInt(getArg('max-rounds', '60'), 10);
651
+ ensureDir(OUTPUT_DIR);
652
+ log(`[PAGE] ${page.url()}`);
653
+ log(`[WATCH] 间隔=${interval}ms, 最大轮数=${maxRounds}`);
654
+
655
+ const errors = [];
656
+ page.on('pageerror', err => errors.push({ time: new Date().toISOString(), error: err.message }));
657
+ page.on('console', msg => {
658
+ if (msg.type() === 'error') errors.push({ time: new Date().toISOString(), type: 'console', text: msg.text() });
659
+ });
660
+
661
+ for (let i = 1; i <= maxRounds; i++) {
662
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
663
+ const fp = path.join(OUTPUT_DIR, `watch-${String(i).padStart(3, '0')}-${ts}.png`);
664
+ await page.screenshot({ path: fp });
665
+ const errCount = errors.length;
666
+ log(`[${i}/${maxRounds}] 截图 | 累计错误: ${errCount}`);
667
+ if (errCount > 0 && i === 1) {
668
+ errors.forEach(e => log(` [ERROR] ${e.error || e.text}`));
669
+ }
670
+ await new Promise(r => setTimeout(r, interval));
671
+ }
672
+
673
+ log(`\n========== 监控结束 ==========`);
674
+ log(`总轮数: ${maxRounds}, 累计错误: ${errors.length}`);
675
+ errors.forEach(e => log(` [${e.time}] ${e.error || e.text}`));
676
+ return { rounds: maxRounds, errors };
677
+ }
678
+
368
679
  // ===== 主入口 =====
369
680
  (async () => {
370
681
  // 检测 CDP
@@ -378,7 +689,23 @@ async function executePlan(browser) {
378
689
  }
379
690
  log(`[CDP] ${cdpInfo.Browser || 'connected'} (${CDP_URL})`);
380
691
 
381
- const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: null });
692
+ // 获取 WebSocket URL
693
+ let wsUrl;
694
+ try {
695
+ const versionData = await new Promise((resolve, reject) => {
696
+ const req = http.get(CDP_URL + '/json/version', { timeout: 3000 }, res => {
697
+ let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(JSON.parse(d)));
698
+ });
699
+ req.on('error', reject);
700
+ });
701
+ wsUrl = versionData.webSocketDebuggerUrl;
702
+ } catch {}
703
+
704
+ const browser = await Promise.race([
705
+ puppeteer.connect({ browserWSEndpoint: wsUrl || CDP_URL, defaultViewport: null }),
706
+ new Promise((_, reject) => setTimeout(() => reject(new Error('CDP WebSocket 连接超时 (10s)')), 10000)),
707
+ ]);
708
+ log('[OK] 浏览器已连接');
382
709
 
383
710
  let result;
384
711
  switch (ACTION) {
@@ -386,10 +713,15 @@ async function executePlan(browser) {
386
713
  case 'diagnose': result = await diagnose(browser); break;
387
714
  case 'screenshot': result = await screenshot(browser); break;
388
715
  case 'api-check': result = await apiCheck(browser); break;
716
+ case 'snapshot': result = await snapshot(browser); break;
717
+ case 'interact': result = await interact(browser); break;
718
+ case 'perf': result = await perf(browser); break;
719
+ case 'watch': result = await watch(browser); break;
389
720
  case 'plan': result = await executePlan(browser); break;
390
721
  default: log(`[ERROR] 未知 action: ${ACTION}`);
391
722
  }
392
723
 
393
724
  browser.disconnect();
394
725
  log('\n[DONE]');
726
+ process.exit(0);
395
727
  })();
@@ -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
  ## 结果解读