@szc-ft/mcp-szcd-client 0.27.2 → 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.
@@ -145,11 +145,14 @@ export class BrowserEngine {
145
145
  const startTime = Date.now();
146
146
  try {
147
147
  const result = await this._dispatchStep(step, context);
148
+ if (step.type === "apiCapture" || step.type === "api-capture") {
149
+ context.lastApiCapture = result;
150
+ }
148
151
  return {
149
152
  ...result,
150
153
  step: step.type,
151
154
  duration: Date.now() - startTime,
152
- status: "PASS",
155
+ status: result?.passed === false ? "FAIL" : "PASS",
153
156
  };
154
157
  } catch (err) {
155
158
  return {
@@ -215,6 +218,10 @@ export class BrowserEngine {
215
218
 
216
219
  async act(options = {}) {
217
220
  const targetFrame = await this._resolveObserveFrame(options);
221
+ if (options.menuPath) {
222
+ return this._actMenuPath(targetFrame, options);
223
+ }
224
+
218
225
  const index = options.index !== undefined ? Number.parseInt(options.index, 10) : undefined;
219
226
  const selector = this._resolveSelector(options.selector || options.click || options.hover);
220
227
  const typeText = options.typeText ?? options.text;
@@ -373,6 +380,160 @@ export class BrowserEngine {
373
380
  };
374
381
  }
375
382
 
383
+ async _actMenuPath(targetFrame, options = {}) {
384
+ const menuPath = Array.isArray(options.menuPath)
385
+ ? options.menuPath
386
+ : String(options.menuPath).split("/").map((item) => item.trim()).filter(Boolean);
387
+ const waitAfterEach = Number.parseInt(options.waitAfterEach ?? 300, 10);
388
+
389
+ if (menuPath.length === 0) {
390
+ throw new Error("menuPath is empty");
391
+ }
392
+
393
+ const result = await targetFrame.frame.evaluate(async (pathItems, waitMs) => {
394
+ function sleep(ms) {
395
+ return new Promise((resolve) => setTimeout(resolve, ms));
396
+ }
397
+
398
+ function textOf(el) {
399
+ return (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim();
400
+ }
401
+
402
+ function classText(el) {
403
+ return typeof el.className === "string" ? el.className : "";
404
+ }
405
+
406
+ function isVisible(el) {
407
+ const rect = el.getBoundingClientRect();
408
+ const style = window.getComputedStyle(el);
409
+ return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
410
+ }
411
+
412
+ function menuCandidates() {
413
+ return Array.from(document.querySelectorAll([
414
+ "[role='menuitem']",
415
+ ".ant-menu-item",
416
+ ".ant-menu-submenu-title",
417
+ "li[class*='menu']",
418
+ "a",
419
+ "button",
420
+ "[class*='menu-item']",
421
+ "[class*='submenu']",
422
+ ].join(","))).filter(isVisible);
423
+ }
424
+
425
+ function scoreCandidate(el, text, level) {
426
+ const actual = textOf(el);
427
+ if (!actual) return -1;
428
+ let score = -1;
429
+ if (actual === text) score = 100;
430
+ else if (actual.includes(text)) score = 70;
431
+ else if (text.includes(actual)) score = 50;
432
+ if (score < 0) return -1;
433
+ const cls = classText(el);
434
+ const role = el.getAttribute("role") || "";
435
+ if (role === "menuitem") score += 10;
436
+ if (cls.includes("ant-menu-submenu-title")) score += level < pathItems.length - 1 ? 20 : -5;
437
+ if (cls.includes("ant-menu-item")) score += level === pathItems.length - 1 ? 20 : 0;
438
+ if (cls.includes("ant-menu-item-selected")) score += 5;
439
+ return score;
440
+ }
441
+
442
+ function findMenuItem(text, level) {
443
+ return menuCandidates()
444
+ .map((el) => ({ el, score: scoreCandidate(el, text, level), text: textOf(el) }))
445
+ .filter((item) => item.score >= 0)
446
+ .sort((a, b) => b.score - a.score)[0] || null;
447
+ }
448
+
449
+ function elementSnapshot(el) {
450
+ const rect = el.getBoundingClientRect();
451
+ return {
452
+ tagName: el.tagName,
453
+ text: textOf(el).slice(0, 120),
454
+ role: el.getAttribute("role") || null,
455
+ className: classText(el),
456
+ ariaExpanded: el.getAttribute("aria-expanded"),
457
+ bbox: {
458
+ x: Math.round(rect.x),
459
+ y: Math.round(rect.y),
460
+ width: Math.round(rect.width),
461
+ height: Math.round(rect.height),
462
+ },
463
+ };
464
+ }
465
+
466
+ const steps = [];
467
+
468
+ for (let i = 0; i < pathItems.length; i++) {
469
+ const text = pathItems[i];
470
+ let match = findMenuItem(text, i);
471
+ if (!match && i > 0) {
472
+ await sleep(waitMs * 2);
473
+ match = findMenuItem(text, i);
474
+ }
475
+ if (!match) {
476
+ return { found: false, failedAt: text, steps };
477
+ }
478
+
479
+ const el = match.el;
480
+ el.scrollIntoView({ block: "center", inline: "center" });
481
+ const beforeUrl = location.href;
482
+ const beforeText = document.body?.innerText || "";
483
+ const isLast = i === pathItems.length - 1;
484
+ const before = elementSnapshot(el);
485
+
486
+ el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
487
+ el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
488
+ el.click();
489
+ await sleep(waitMs);
490
+
491
+ const afterClass = classText(el);
492
+ const afterExpanded = el.getAttribute("aria-expanded");
493
+ steps.push({
494
+ text,
495
+ action: isLast ? "click" : "expand",
496
+ status: "PASS",
497
+ matchedText: match.text,
498
+ before,
499
+ after: {
500
+ className: afterClass,
501
+ ariaExpanded: afterExpanded,
502
+ urlChanged: location.href !== beforeUrl,
503
+ textChanged: (document.body?.innerText || "") !== beforeText,
504
+ selected: afterClass.includes("selected") || afterClass.includes("active"),
505
+ },
506
+ });
507
+ }
508
+
509
+ return {
510
+ found: true,
511
+ performed: true,
512
+ steps,
513
+ currentUrl: location.href,
514
+ title: document.title,
515
+ };
516
+ }, menuPath, waitAfterEach);
517
+
518
+ if (!result.found) {
519
+ throw new Error(`Menu path item not found: ${result.failedAt}`);
520
+ }
521
+
522
+ return {
523
+ acted: true,
524
+ action: "menuPath",
525
+ menuPath,
526
+ target: {
527
+ frameId: targetFrame.frameId,
528
+ frameUrl: targetFrame.frame.url(),
529
+ matchedBy: targetFrame.matchedBy,
530
+ },
531
+ steps: result.steps,
532
+ currentUrl: result.currentUrl,
533
+ title: result.title,
534
+ };
535
+ }
536
+
376
537
  async assert(options = {}) {
377
538
  const targetFrame = await this._resolveObserveFrame(options);
378
539
  const previousFrame = this._activeFrame;
@@ -417,6 +578,237 @@ export class BrowserEngine {
417
578
  }
418
579
  }
419
580
 
581
+ async captureApi(options = {}) {
582
+ const wait = Number.parseInt(options.wait ?? options.timeout ?? 15000, 10);
583
+ const includeRequestBody = options.includeRequestBody !== false;
584
+ const includeResponseBody = Boolean(options.includeResponseBody);
585
+ const methodFilter = options.method ? String(options.method).toUpperCase() : null;
586
+ const urlPattern = options.urlPattern || options.urlIncludes || "";
587
+ const targetFrame = await this._resolveObserveFrame(options).catch(() => null);
588
+ const requests = new Map();
589
+ const sessions = [];
590
+ const targets = [];
591
+ const pendingBodies = [];
592
+ const startedAt = Date.now();
593
+
594
+ const matchesUrl = (url = "") => !urlPattern || url.includes(urlPattern);
595
+ const matchesMethod = (method = "") => !methodFilter || method.toUpperCase() === methodFilter;
596
+ const normalizeHeaders = (headers = {}) => Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, String(value)]));
597
+
598
+ const addSession = async (session, targetInfo) => {
599
+ await session.send("Network.enable").catch(() => null);
600
+ sessions.push(session);
601
+ targets.push(targetInfo);
602
+
603
+ session.on("Network.requestWillBeSent", (event) => {
604
+ const request = event.request || {};
605
+ if (!matchesUrl(request.url) || !matchesMethod(request.method)) return;
606
+ requests.set(`${targetInfo.id}:${event.requestId}`, {
607
+ id: event.requestId,
608
+ targetId: targetInfo.id,
609
+ targetType: targetInfo.type,
610
+ targetUrl: targetInfo.url,
611
+ frameId: event.frameId,
612
+ url: request.url,
613
+ method: request.method,
614
+ requestHeaders: normalizeHeaders(request.headers),
615
+ requestBody: includeRequestBody ? request.postData || null : undefined,
616
+ status: null,
617
+ responseHeaders: null,
618
+ responseBody: null,
619
+ responseBodyBase64Encoded: null,
620
+ errorText: null,
621
+ startTime: Date.now(),
622
+ endTime: null,
623
+ duration: null,
624
+ });
625
+ });
626
+
627
+ session.on("Network.responseReceived", (event) => {
628
+ const item = requests.get(`${targetInfo.id}:${event.requestId}`);
629
+ if (!item) return;
630
+ const response = event.response || {};
631
+ item.status = response.status;
632
+ item.mimeType = response.mimeType;
633
+ item.responseHeaders = normalizeHeaders(response.headers);
634
+ item.responseUrl = response.url;
635
+ });
636
+
637
+ session.on("Network.loadingFinished", async (event) => {
638
+ const item = requests.get(`${targetInfo.id}:${event.requestId}`);
639
+ if (!item) return;
640
+ item.endTime = Date.now();
641
+ item.duration = item.endTime - item.startTime;
642
+ if (includeResponseBody) {
643
+ const bodyTask = session.send("Network.getResponseBody", { requestId: event.requestId })
644
+ .then((body) => {
645
+ item.responseBody = body.body;
646
+ item.responseBodyBase64Encoded = body.base64Encoded;
647
+ })
648
+ .catch((err) => {
649
+ item.responseBodyError = err.message;
650
+ });
651
+ pendingBodies.push(bodyTask);
652
+ }
653
+ });
654
+
655
+ session.on("Network.loadingFailed", (event) => {
656
+ const item = requests.get(`${targetInfo.id}:${event.requestId}`);
657
+ if (!item) return;
658
+ item.endTime = Date.now();
659
+ item.duration = item.endTime - item.startTime;
660
+ item.errorText = event.errorText || "loadingFailed";
661
+ });
662
+ };
663
+
664
+ const pageSession = await this.page.target().createCDPSession();
665
+ await addSession(pageSession, {
666
+ id: "page",
667
+ type: "page",
668
+ url: this.page.url(),
669
+ matchedBy: "currentPage",
670
+ });
671
+
672
+ const pageTarget = this.page.target();
673
+ const attachCandidates = this.browser.targets().filter((target) => {
674
+ if (target === pageTarget) return false;
675
+ if (!["iframe", "page"].includes(target.type())) return false;
676
+ const targetUrl = target.url() || "";
677
+ if (options.targetUrlContains && !targetUrl.includes(options.targetUrlContains)) return false;
678
+ if (targetFrame?.frame && targetUrl && targetFrame.frame.url() && targetUrl !== targetFrame.frame.url() && !targetUrl.includes(targetFrame.frame.url())) {
679
+ return options.includeAllTargets === true;
680
+ }
681
+ return true;
682
+ });
683
+
684
+ for (const target of attachCandidates) {
685
+ const session = await target.createCDPSession().catch(() => null);
686
+ if (!session) continue;
687
+ await addSession(session, {
688
+ id: target.url() || `${target.type()}-${targets.length}`,
689
+ type: target.type(),
690
+ url: target.url() || "",
691
+ matchedBy: "browser.targets",
692
+ });
693
+ }
694
+
695
+ if (options.reload) {
696
+ await this.page.reload({ waitUntil: options.waitUntil || "domcontentloaded", timeout: options.reloadTimeout || 30000 }).catch(() => null);
697
+ }
698
+
699
+ await new Promise((resolve) => setTimeout(resolve, wait));
700
+ await Promise.allSettled(pendingBodies);
701
+
702
+ await Promise.all(sessions.map((session) => session.send("Network.disable").catch(() => null)));
703
+
704
+ const requestList = Array.from(requests.values()).sort((a, b) => a.startTime - b.startTime);
705
+ const title = await this.page.title().catch(() => "");
706
+ const failed = requestList.filter((item) => item.errorText || (item.status && item.status >= 400));
707
+
708
+ return {
709
+ type: "apiCapture",
710
+ page: {
711
+ url: this.page.url(),
712
+ title,
713
+ },
714
+ target: targetFrame ? {
715
+ frameId: targetFrame.frameId,
716
+ frameUrl: targetFrame.frame.url(),
717
+ matchedBy: targetFrame.matchedBy,
718
+ } : null,
719
+ targets,
720
+ requests: requestList,
721
+ summary: {
722
+ total: requestList.length,
723
+ success: requestList.length - failed.length,
724
+ failed: failed.length,
725
+ targets: targets.length,
726
+ duration: Date.now() - startedAt,
727
+ },
728
+ };
729
+ }
730
+
731
+ assertApi(captureResult, options = {}) {
732
+ if (!captureResult || !Array.isArray(captureResult.requests)) {
733
+ throw new Error("assertApi requires an apiCapture result");
734
+ }
735
+
736
+ const requests = captureResult.requests;
737
+ const expect = options.expect || options;
738
+ const checks = {};
739
+ let passed = true;
740
+
741
+ if (expect.maxFailed !== undefined) {
742
+ const actual = requests.filter((item) => item.errorText || (item.status && item.status >= 400)).length;
743
+ checks.maxFailed = {
744
+ expected: Number.parseInt(expect.maxFailed, 10),
745
+ actual,
746
+ passed: actual <= Number.parseInt(expect.maxFailed, 10),
747
+ };
748
+ if (!checks.maxFailed.passed) passed = false;
749
+ }
750
+
751
+ if (expect.forbidStatusGte !== undefined) {
752
+ const threshold = Number.parseInt(expect.forbidStatusGte, 10);
753
+ const violations = requests
754
+ .filter((item) => item.status && item.status >= threshold)
755
+ .map((item) => ({ url: item.url, method: item.method, status: item.status }));
756
+ checks.forbidStatusGte = {
757
+ expected: threshold,
758
+ violations,
759
+ passed: violations.length === 0,
760
+ };
761
+ if (!checks.forbidStatusGte.passed) passed = false;
762
+ }
763
+
764
+ if (expect.requiredUrls !== undefined) {
765
+ const requiredUrls = Array.isArray(expect.requiredUrls) ? expect.requiredUrls : [expect.requiredUrls];
766
+ checks.requiredUrls = requiredUrls.map((url) => {
767
+ const matched = requests.filter((item) => item.url?.includes(url));
768
+ return {
769
+ url,
770
+ matched: matched.length,
771
+ passed: matched.length > 0,
772
+ };
773
+ });
774
+ if (checks.requiredUrls.some((item) => !item.passed)) passed = false;
775
+ }
776
+
777
+ if (expect.requiredMethods !== undefined) {
778
+ const requiredMethods = Array.isArray(expect.requiredMethods) ? expect.requiredMethods : [expect.requiredMethods];
779
+ checks.requiredMethods = requiredMethods.map((method) => {
780
+ const normalized = String(method).toUpperCase();
781
+ const matched = requests.filter((item) => item.method === normalized);
782
+ return {
783
+ method: normalized,
784
+ matched: matched.length,
785
+ passed: matched.length > 0,
786
+ };
787
+ });
788
+ if (checks.requiredMethods.some((item) => !item.passed)) passed = false;
789
+ }
790
+
791
+ if (expect.minTotal !== undefined) {
792
+ const expected = Number.parseInt(expect.minTotal, 10);
793
+ checks.minTotal = {
794
+ expected,
795
+ actual: requests.length,
796
+ passed: requests.length >= expected,
797
+ };
798
+ if (!checks.minTotal.passed) passed = false;
799
+ }
800
+
801
+ return {
802
+ type: "assertApi",
803
+ passed,
804
+ checks,
805
+ summary: captureResult?.summary || {
806
+ total: requests.length,
807
+ failed: requests.filter((item) => item.errorText || (item.status && item.status >= 400)).length,
808
+ },
809
+ };
810
+ }
811
+
420
812
  async _resolveObserveFrame(options = {}) {
421
813
  const frames = this.page.frames();
422
814
  const mainFrame = this.page.mainFrame();
@@ -613,6 +1005,10 @@ export class BrowserEngine {
613
1005
  case "observe": return this.observe(step);
614
1006
  case "act": return this.act(step);
615
1007
  case "assert": return this.assert(step);
1008
+ case "apiCapture": return this.captureApi(step);
1009
+ case "api-capture": return this.captureApi(step);
1010
+ case "assertApi": return this.assertApi(step.captureResult || context.lastApiCapture, step);
1011
+ case "assert-api": return this.assertApi(step.captureResult || context.lastApiCapture, step);
616
1012
  case "navigate": return this._navigate(step);
617
1013
  case "screenshot": return this._screenshot(step, context);
618
1014
  case "compare": return this._compare(step, context);
@@ -5,11 +5,14 @@
5
5
  * 用法:
6
6
  * node local-browser-executor.js --action observe --output /tmp/observe.json
7
7
  * node local-browser-executor.js --action act --index 1 --observe-after
8
+ * node local-browser-executor.js --action act --menu-path "目录管理/数据集目录" --observe-after
9
+ * node local-browser-executor.js --action api-capture --wait 15000 --include-response-body
10
+ * node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0
8
11
  * node local-browser-executor.js --plan '<JSON>' --output /tmp/result.json
9
12
  * node local-browser-executor.js --plan-file /path/to/plan.json --output /tmp/result.json
10
13
  *
11
14
  * 参数:
12
- * --action 直接执行动作,当前支持 observe/act/assert
15
+ * --action 直接执行动作,当前支持 observe/act/assert/api-capture
13
16
  * --plan 测试计划 JSON 字符串
14
17
  * --plan-file 测试计划 JSON 文件路径(与 --plan 二选一)
15
18
  * --output 结果输出文件路径(默认 stdout)
@@ -93,6 +96,12 @@ function parseArgs() {
93
96
  case "--hover":
94
97
  parsed.hover = args[++i];
95
98
  break;
99
+ case "--menu-path":
100
+ parsed.menuPath = args[++i];
101
+ break;
102
+ case "--wait-after-each":
103
+ parsed.waitAfterEach = args[++i];
104
+ break;
96
105
  case "--observe-after":
97
106
  parsed.observeAfter = true;
98
107
  break;
@@ -111,6 +120,51 @@ function parseArgs() {
111
120
  case "--has-text":
112
121
  parsed.hasText = args[++i];
113
122
  break;
123
+ case "--wait":
124
+ parsed.wait = args[++i];
125
+ break;
126
+ case "--method":
127
+ parsed.method = args[++i];
128
+ break;
129
+ case "--url-pattern":
130
+ parsed.urlPattern = args[++i];
131
+ break;
132
+ case "--target-url-contains":
133
+ parsed.targetUrlContains = args[++i];
134
+ break;
135
+ case "--include-request-body":
136
+ parsed.includeRequestBody = true;
137
+ break;
138
+ case "--no-request-body":
139
+ parsed.includeRequestBody = false;
140
+ break;
141
+ case "--include-response-body":
142
+ parsed.includeResponseBody = true;
143
+ break;
144
+ case "--reload":
145
+ parsed.reload = true;
146
+ break;
147
+ case "--include-all-targets":
148
+ parsed.includeAllTargets = true;
149
+ break;
150
+ case "--capture-file":
151
+ parsed.captureFile = args[++i];
152
+ break;
153
+ case "--max-failed":
154
+ parsed.maxFailed = args[++i];
155
+ break;
156
+ case "--forbid-status-gte":
157
+ parsed.forbidStatusGte = args[++i];
158
+ break;
159
+ case "--required-url":
160
+ parsed.requiredUrls = [...(parsed.requiredUrls || []), args[++i]];
161
+ break;
162
+ case "--required-method":
163
+ parsed.requiredMethods = [...(parsed.requiredMethods || []), args[++i]];
164
+ break;
165
+ case "--min-total":
166
+ parsed.minTotal = args[++i];
167
+ break;
114
168
  case "--base-url":
115
169
  parsed.baseUrl = args[++i];
116
170
  break;
@@ -133,12 +187,15 @@ function printHelp() {
133
187
  用法:
134
188
  node local-browser-executor.js --action observe [options]
135
189
  node local-browser-executor.js --action act --index 1 --observe-after [options]
190
+ node local-browser-executor.js --action act --menu-path "目录管理/数据集目录" --observe-after [options]
136
191
  node local-browser-executor.js --action assert --selector ".ant-table" --expect-visible [options]
192
+ node local-browser-executor.js --action api-capture --wait 15000 --include-response-body [options]
193
+ node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0 [options]
137
194
  node local-browser-executor.js --plan '<JSON>' [options]
138
195
  node local-browser-executor.js --plan-file <path> [options]
139
196
 
140
197
  选项:
141
- --action <name> 直接执行动作,当前支持 observe/act/assert
198
+ --action <name> 直接执行动作,当前支持 observe/act/assert/api-capture
142
199
  --plan <json> 测试计划 JSON 字符串
143
200
  --plan-file <path> 测试计划 JSON 文件路径
144
201
  --output <path> 结果输出文件路径(默认 stdout)
@@ -156,12 +213,29 @@ function printHelp() {
156
213
  --type <text> act 时输入文本
157
214
  --clear act 输入前清空目标
158
215
  --hover <selector> act 时悬停 selector/text=/role=/~ 目标
216
+ --menu-path <a/b> act 时按菜单路径展开并点击,如 "目录管理/数据集目录"
217
+ --wait-after-each <ms> menu-path 每步点击后的等待时间(默认 300)
159
218
  --observe-after act 后再次 observe,返回 before/act/after 闭环结果
160
219
  --url-contains-assert <text> assert 当前 URL 包含文本
161
220
  --text-contains <text> assert 页面文本包含内容
162
221
  --expect-visible assert selector 可见
163
222
  --min-count <n> assert selector 数量下限
164
223
  --has-text <text> assert selector 文本包含内容
224
+ --wait <ms> api-capture 监听时长(默认 15000)
225
+ --method <GET|POST> api-capture 按请求方法过滤
226
+ --url-pattern <text> api-capture 按 URL 片段过滤
227
+ --target-url-contains <text> api-capture 按 target URL 过滤 iframe/page target
228
+ --include-request-body api-capture 包含请求体(默认包含)
229
+ --no-request-body api-capture 不包含请求体
230
+ --include-response-body api-capture 调用 Network.getResponseBody 获取响应体
231
+ --reload api-capture 开始监听后刷新页面触发请求
232
+ --include-all-targets api-capture 不限制到匹配 frame,监听所有 page/iframe targets
233
+ --capture-file <path> assert-api 读取 api-capture JSON 结果
234
+ --max-failed <n> assert-api 允许失败请求数上限
235
+ --forbid-status-gte <n> assert-api 禁止出现大于等于该状态码的响应
236
+ --required-url <text> assert-api 要求出现的 URL 片段,可重复传入
237
+ --required-method <GET|POST> assert-api 要求出现的请求方法,可重复传入
238
+ --min-total <n> assert-api 要求捕获请求总数下限
165
239
  --allow-launch action 模式允许无 CDP 时启动 headless Chrome
166
240
  --base-url <url> 基础 URL,替换计划中的 {{baseUrl}} 占位符
167
241
  -h, --help 显示帮助
@@ -269,6 +343,25 @@ async function executePlan(plan, options) {
269
343
 
270
344
  // ==================== 主流程 ====================
271
345
 
346
+ function executeAssertApiAction(args) {
347
+ if (!args.captureFile) {
348
+ return {
349
+ error: "必须提供 --capture-file 参数",
350
+ errorCode: "CAPTURE_FILE_REQUIRED",
351
+ };
352
+ }
353
+
354
+ const captureResult = JSON.parse(fs.readFileSync(args.captureFile, "utf8"));
355
+ const engine = new BrowserEngine();
356
+ return engine.assertApi(captureResult, {
357
+ maxFailed: args.maxFailed,
358
+ forbidStatusGte: args.forbidStatusGte,
359
+ requiredUrls: args.requiredUrls,
360
+ requiredMethods: args.requiredMethods,
361
+ minTotal: args.minTotal,
362
+ });
363
+ }
364
+
272
365
  async function executeAction(args) {
273
366
  const engine = new BrowserEngine();
274
367
  const startedAt = new Date().toISOString();
@@ -308,6 +401,8 @@ async function executeAction(args) {
308
401
  selector: args.selector,
309
402
  click: args.click,
310
403
  hover: args.hover,
404
+ menuPath: args.menuPath,
405
+ waitAfterEach: args.waitAfterEach,
311
406
  typeText: args.typeText,
312
407
  clear: args.clear,
313
408
  frameUrlContains: args.frameUrlContains,
@@ -342,11 +437,28 @@ async function executeAction(args) {
342
437
  });
343
438
  return { type: "assert", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
344
439
  }
440
+ case "api-capture":
441
+ case "apiCapture": {
442
+ const result = await engine.captureApi({
443
+ wait: args.wait,
444
+ method: args.method,
445
+ urlPattern: args.urlPattern,
446
+ targetUrlContains: args.targetUrlContains,
447
+ includeRequestBody: args.includeRequestBody,
448
+ includeResponseBody: args.includeResponseBody,
449
+ reload: args.reload,
450
+ includeAllTargets: args.includeAllTargets,
451
+ frameUrlContains: args.frameUrlContains,
452
+ frameContentContains: args.frameContentContains,
453
+ frameIndex: args.frameIndex,
454
+ });
455
+ return { ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
456
+ }
345
457
  default:
346
458
  return {
347
459
  error: `未知 action: ${args.action}`,
348
460
  errorCode: "UNKNOWN_ACTION",
349
- supportedActions: ["observe", "act", "assert"],
461
+ supportedActions: ["observe", "act", "assert", "api-capture"],
350
462
  startTime: startedAt,
351
463
  endTime: new Date().toISOString(),
352
464
  };
@@ -367,6 +479,21 @@ async function executeAction(args) {
367
479
  async function main() {
368
480
  const args = parseArgs();
369
481
 
482
+ if (args.action === "assert-api" || args.action === "assertApi") {
483
+ try {
484
+ const results = executeAssertApiAction(args);
485
+ outputResult(results, args.output);
486
+ process.exit(results.error ? 2 : results.passed === false ? 1 : 0);
487
+ } catch (err) {
488
+ const errorResult = {
489
+ error: err.message,
490
+ errorCode: "ASSERT_API_FAILED",
491
+ };
492
+ outputResult(errorResult, args.output);
493
+ process.exit(2);
494
+ }
495
+ }
496
+
370
497
  if (args.action) {
371
498
  const results = await executeAction(args);
372
499
  outputResult(results, args.output);
@@ -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[] }` |
@@ -236,6 +238,18 @@ node {client_path}/local-browser-executor.js \
236
238
  --output /tmp/browser-act-observe.json
237
239
  ```
238
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
+
239
253
  ### 轻量 DOM 断言
240
254
  ```bash
241
255
  node {client_path}/local-browser-executor.js \
@@ -247,6 +261,34 @@ node {client_path}/local-browser-executor.js \
247
261
  --output /tmp/browser-assert.json
248
262
  ```
249
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
+
250
292
  ### 快速诊断(过渡兼容)
251
293
  ```bash
252
294
  NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@szc-ft/mcp-szcd-client",
3
- "version": "0.27.2",
3
+ "version": "0.27.3",
4
4
  "description": "MCP client for szcd component library - auto-configures AI coding tools with MCP server, skills, agents and commands",
5
5
  "keywords": [
6
6
  "mcp",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "szcd-component-helper",
3
- "version": "0.27.2",
3
+ "version": "0.27.3",
4
4
  "description": "szcd 组件库 MCP 助手 — 查询组件信息、匹配需求、生成代码",
5
5
  "mcpServers": {
6
6
  "szcd-component-helper": {
@@ -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[] }` |
@@ -236,6 +238,18 @@ node {client_path}/local-browser-executor.js \
236
238
  --output /tmp/browser-act-observe.json
237
239
  ```
238
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
+
239
253
  ### 轻量 DOM 断言
240
254
  ```bash
241
255
  node {client_path}/local-browser-executor.js \
@@ -247,6 +261,34 @@ node {client_path}/local-browser-executor.js \
247
261
  --output /tmp/browser-assert.json
248
262
  ```
249
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
+
250
292
  ### 快速诊断(过渡兼容)
251
293
  ```bash
252
294
  NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
@@ -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[] }` |
@@ -236,6 +238,18 @@ node {client_path}/local-browser-executor.js \
236
238
  --output /tmp/browser-act-observe.json
237
239
  ```
238
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
+
239
253
  ### 轻量 DOM 断言
240
254
  ```bash
241
255
  node {client_path}/local-browser-executor.js \
@@ -247,6 +261,34 @@ node {client_path}/local-browser-executor.js \
247
261
  --output /tmp/browser-assert.json
248
262
  ```
249
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
+
250
292
  ### 快速诊断(过渡兼容)
251
293
  ```bash
252
294
  NODE_PATH=~/.szcd-mcp/deps/node_modules node {skill_path}/local-browser-executor.cjs \
@@ -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
- })();