@szc-ft/mcp-szcd-client 0.27.2 → 0.27.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -262,12 +269,28 @@ export class BrowserEngine {
262
269
  .filter(isCandidate);
263
270
  }
264
271
 
272
+ function isVisibleEnabled(el) {
273
+ const rect = el.getBoundingClientRect();
274
+ const style = window.getComputedStyle(el);
275
+ if (rect.width <= 0 || rect.height <= 0) return false;
276
+ if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false;
277
+ if (el.disabled || el.getAttribute("aria-disabled") === "true") return false;
278
+ return true;
279
+ }
280
+
265
281
  function findBySelector(selector) {
266
282
  if (!selector) return null;
267
283
  if (selector.startsWith("text=")) {
268
284
  const expected = selector.slice(5).trim();
269
- return interactiveElements().find((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected)) ||
270
- Array.from(document.querySelectorAll("*")).find((el) => getText(el).includes(expected));
285
+ // 默认仅匹配 visible+enabled 元素,避免命中 ant-select 的隐藏 input、收起菜单里的 li
286
+ // 需要包含隐藏元素时显式 includeHidden=true
287
+ const includeHidden = actOptions.includeHidden === true;
288
+ const filter = (el) => includeHidden || isVisibleEnabled(el);
289
+ const interactive = interactiveElements().filter(filter);
290
+ let hit = interactive.find((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected));
291
+ if (hit) return hit;
292
+ const all = Array.from(document.querySelectorAll("*")).filter(filter);
293
+ return all.find((el) => getText(el).includes(expected)) || null;
271
294
  }
272
295
  if (selector.startsWith("role=")) {
273
296
  const match = selector.match(/^role=([^\[]+)(?:\[name="([^"]+)"\])?/);
@@ -350,6 +373,7 @@ export class BrowserEngine {
350
373
  action,
351
374
  typeText,
352
375
  clear: Boolean(options.clear),
376
+ includeHidden: Boolean(options.includeHidden),
353
377
  });
354
378
 
355
379
  if (!result.found) {
@@ -373,6 +397,227 @@ export class BrowserEngine {
373
397
  };
374
398
  }
375
399
 
400
+ async _actMenuPath(targetFrame, options = {}) {
401
+ const menuPath = Array.isArray(options.menuPath)
402
+ ? options.menuPath
403
+ : String(options.menuPath).split("/").map((item) => item.trim()).filter(Boolean);
404
+ const waitAfterEach = Number.parseInt(options.waitAfterEach ?? 300, 10);
405
+ const drawerTriggerSelector = options.drawerTriggerSelector || null;
406
+ const drawerOpenSelector = options.drawerOpenSelector || ".ant-drawer-open, .ant-drawer.ant-drawer-open";
407
+ const scrollContainerSelector = options.menuScrollContainer || null;
408
+ const useNativeHover = options.useNativeHover !== false; // 默认开
409
+ const maxRetryPerStep = Number.parseInt(options.maxRetryPerStep ?? 2, 10);
410
+
411
+ if (menuPath.length === 0) {
412
+ throw new Error("menuPath is empty");
413
+ }
414
+
415
+ const frame = targetFrame.frame;
416
+
417
+ const ensureDrawerOpen = async () => {
418
+ if (!drawerTriggerSelector) return { ensured: false, reason: "no drawer" };
419
+ const isOpen = await frame.evaluate((sel) => !!document.querySelector(sel), drawerOpenSelector).catch(() => false);
420
+ if (isOpen) return { ensured: true, reason: "already open" };
421
+ const triggered = await frame.evaluate((sel) => {
422
+ const el = document.querySelector(sel);
423
+ if (!el) return false;
424
+ el.scrollIntoView({ block: "center", inline: "center" });
425
+ el.click();
426
+ return true;
427
+ }, drawerTriggerSelector).catch(() => false);
428
+ if (!triggered) return { ensured: false, reason: "trigger not found" };
429
+ await new Promise((r) => setTimeout(r, waitAfterEach));
430
+ return { ensured: true, reason: "clicked trigger" };
431
+ };
432
+
433
+ const scrollContainerOnce = async () => {
434
+ if (!scrollContainerSelector) return false;
435
+ return frame.evaluate((sel) => {
436
+ const c = document.querySelector(sel);
437
+ if (!c) return false;
438
+ c.scrollTop = Math.min(c.scrollTop + Math.max(c.clientHeight - 40, 200), c.scrollHeight);
439
+ return true;
440
+ }, scrollContainerSelector).catch(() => false);
441
+ };
442
+
443
+ const findStep = async (text, level) => {
444
+ return frame.evaluate((expectedText, lvl, totalLevels) => {
445
+ function textOf(el) {
446
+ return (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim();
447
+ }
448
+ function classText(el) {
449
+ return typeof el.className === "string" ? el.className : "";
450
+ }
451
+ function isVisible(el) {
452
+ const rect = el.getBoundingClientRect();
453
+ const style = window.getComputedStyle(el);
454
+ return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
455
+ }
456
+ function menuCandidates() {
457
+ return Array.from(document.querySelectorAll([
458
+ "[role='menuitem']",
459
+ ".ant-menu-item",
460
+ ".ant-menu-submenu-title",
461
+ "li[class*='menu']",
462
+ "a",
463
+ "button",
464
+ "[class*='menu-item']",
465
+ "[class*='submenu']",
466
+ ].join(","))).filter(isVisible);
467
+ }
468
+ function score(el) {
469
+ const actual = textOf(el);
470
+ if (!actual) return -1;
471
+ let s = -1;
472
+ if (actual === expectedText) s = 100;
473
+ else if (actual.includes(expectedText)) s = 70;
474
+ else if (expectedText.includes(actual)) s = 50;
475
+ if (s < 0) return -1;
476
+ const cls = classText(el);
477
+ const role = el.getAttribute("role") || "";
478
+ if (role === "menuitem") s += 10;
479
+ if (cls.includes("ant-menu-submenu-title")) s += lvl < totalLevels - 1 ? 20 : -5;
480
+ if (cls.includes("ant-menu-item")) s += lvl === totalLevels - 1 ? 20 : 0;
481
+ if (cls.includes("ant-menu-item-selected")) s += 5;
482
+ return s;
483
+ }
484
+ const ranked = menuCandidates()
485
+ .map((el) => ({ el, s: score(el), text: textOf(el) }))
486
+ .filter((x) => x.s >= 0)
487
+ .sort((a, b) => b.s - a.s);
488
+ const top = ranked[0];
489
+ if (!top) {
490
+ return {
491
+ found: false,
492
+ candidates: menuCandidates().slice(0, 8).map((el) => ({
493
+ text: textOf(el).slice(0, 80),
494
+ className: classText(el).slice(0, 120),
495
+ })),
496
+ };
497
+ }
498
+ // 用稳定 attr 标记,避免后续 click 时 ranking 漂移
499
+ top.el.setAttribute("data-szcd-menu-target", "1");
500
+ const rect = top.el.getBoundingClientRect();
501
+ return {
502
+ found: true,
503
+ matchedText: top.text,
504
+ bbox: {
505
+ x: Math.round(rect.x), y: Math.round(rect.y),
506
+ width: Math.round(rect.width), height: Math.round(rect.height),
507
+ },
508
+ snapshot: {
509
+ tagName: top.el.tagName,
510
+ text: textOf(top.el).slice(0, 120),
511
+ role: top.el.getAttribute("role") || null,
512
+ className: classText(top.el),
513
+ ariaExpanded: top.el.getAttribute("aria-expanded"),
514
+ },
515
+ };
516
+ }, text, level, menuPath.length).catch(() => ({ found: false, candidates: [] }));
517
+ };
518
+
519
+ const clickStep = async (isLast) => {
520
+ return frame.evaluate(async (waitMs, last) => {
521
+ const el = document.querySelector("[data-szcd-menu-target='1']");
522
+ if (!el) return { performed: false, error: "marker lost" };
523
+ el.scrollIntoView({ block: "center", inline: "center" });
524
+ const beforeUrl = location.href;
525
+ const beforeText = document.body?.innerText || "";
526
+ el.click();
527
+ el.removeAttribute("data-szcd-menu-target");
528
+ await new Promise((r) => setTimeout(r, waitMs));
529
+ const cls = typeof el.className === "string" ? el.className : "";
530
+ return {
531
+ performed: true,
532
+ action: last ? "click" : "expand",
533
+ after: {
534
+ className: cls,
535
+ ariaExpanded: el.getAttribute("aria-expanded"),
536
+ urlChanged: location.href !== beforeUrl,
537
+ textChanged: (document.body?.innerText || "") !== beforeText,
538
+ selected: cls.includes("selected") || cls.includes("active"),
539
+ },
540
+ };
541
+ }, waitAfterEach, isLast);
542
+ };
543
+
544
+ const steps = [];
545
+
546
+ for (let i = 0; i < menuPath.length; i++) {
547
+ const text = menuPath[i];
548
+ const isLast = i === menuPath.length - 1;
549
+
550
+ // 抽屉式菜单:每级前都先确认抽屉是开的
551
+ const drawerState = await ensureDrawerOpen();
552
+
553
+ let match = await findStep(text, i);
554
+ let attempts = 0;
555
+ while (!match.found && attempts < maxRetryPerStep) {
556
+ attempts += 1;
557
+ // 滚动一次再找
558
+ const scrolled = await scrollContainerOnce();
559
+ if (!scrolled) await new Promise((r) => setTimeout(r, waitAfterEach));
560
+ match = await findStep(text, i);
561
+ }
562
+
563
+ if (!match.found) {
564
+ return {
565
+ acted: false,
566
+ action: "menuPath",
567
+ menuPath,
568
+ target: {
569
+ frameId: targetFrame.frameId,
570
+ frameUrl: targetFrame.frame.url(),
571
+ matchedBy: targetFrame.matchedBy,
572
+ },
573
+ failedAt: text,
574
+ failedAtIndex: i,
575
+ candidates: match.candidates || [],
576
+ drawerState,
577
+ steps,
578
+ };
579
+ }
580
+
581
+ // native hover(puppeteer frame.hover),优于 dispatchEvent
582
+ if (useNativeHover && typeof frame.hover === "function") {
583
+ try {
584
+ await frame.hover("[data-szcd-menu-target='1']");
585
+ } catch {
586
+ // hover 失败不致命,继续 click
587
+ }
588
+ }
589
+
590
+ const clickRes = await clickStep(isLast);
591
+ steps.push({
592
+ text,
593
+ action: clickRes.action || (isLast ? "click" : "expand"),
594
+ status: clickRes.performed ? "PASS" : "FAIL",
595
+ matchedText: match.matchedText,
596
+ before: match.snapshot,
597
+ after: clickRes.after || null,
598
+ drawerEnsured: drawerState.ensured,
599
+ retries: attempts,
600
+ });
601
+ }
602
+
603
+ const currentUrl = this.page.url();
604
+ const title = await this.page.title().catch(() => "");
605
+
606
+ return {
607
+ acted: true,
608
+ action: "menuPath",
609
+ menuPath,
610
+ target: {
611
+ frameId: targetFrame.frameId,
612
+ frameUrl: targetFrame.frame.url(),
613
+ matchedBy: targetFrame.matchedBy,
614
+ },
615
+ steps,
616
+ currentUrl,
617
+ title,
618
+ };
619
+ }
620
+
376
621
  async assert(options = {}) {
377
622
  const targetFrame = await this._resolveObserveFrame(options);
378
623
  const previousFrame = this._activeFrame;
@@ -417,6 +662,256 @@ export class BrowserEngine {
417
662
  }
418
663
  }
419
664
 
665
+ async captureApi(options = {}) {
666
+ const wait = Number.parseInt(options.wait ?? options.timeout ?? 15000, 10);
667
+ const includeRequestBody = options.includeRequestBody !== false;
668
+ const includeResponseBody = Boolean(options.includeResponseBody);
669
+ const methodFilter = options.method ? String(options.method).toUpperCase() : null;
670
+ const urlPattern = options.urlPattern || options.urlIncludes || "";
671
+ const targetFrame = await this._resolveObserveFrame(options).catch(() => null);
672
+ const requests = new Map();
673
+ const sessions = [];
674
+ const targets = [];
675
+ const pendingBodies = [];
676
+ const startedAt = Date.now();
677
+
678
+ const matchesUrl = (url = "") => !urlPattern || url.includes(urlPattern);
679
+ const matchesMethod = (method = "") => !methodFilter || method.toUpperCase() === methodFilter;
680
+ const normalizeHeaders = (headers = {}) => Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, String(value)]));
681
+
682
+ const addSession = async (session, targetInfo) => {
683
+ await session.send("Network.enable").catch(() => null);
684
+ sessions.push(session);
685
+ targets.push(targetInfo);
686
+
687
+ session.on("Network.requestWillBeSent", (event) => {
688
+ const request = event.request || {};
689
+ if (!matchesUrl(request.url) || !matchesMethod(request.method)) return;
690
+ requests.set(`${targetInfo.id}:${event.requestId}`, {
691
+ id: event.requestId,
692
+ targetId: targetInfo.id,
693
+ targetType: targetInfo.type,
694
+ targetUrl: targetInfo.url,
695
+ frameId: event.frameId,
696
+ url: request.url,
697
+ method: request.method,
698
+ requestHeaders: normalizeHeaders(request.headers),
699
+ requestBody: includeRequestBody ? request.postData || null : undefined,
700
+ status: null,
701
+ responseHeaders: null,
702
+ responseBody: null,
703
+ responseBodyBase64Encoded: null,
704
+ errorText: null,
705
+ startTime: Date.now(),
706
+ endTime: null,
707
+ duration: null,
708
+ });
709
+ });
710
+
711
+ session.on("Network.responseReceived", (event) => {
712
+ const item = requests.get(`${targetInfo.id}:${event.requestId}`);
713
+ if (!item) return;
714
+ const response = event.response || {};
715
+ item.status = response.status;
716
+ item.mimeType = response.mimeType;
717
+ item.responseHeaders = normalizeHeaders(response.headers);
718
+ item.responseUrl = response.url;
719
+ });
720
+
721
+ session.on("Network.loadingFinished", async (event) => {
722
+ const item = requests.get(`${targetInfo.id}:${event.requestId}`);
723
+ if (!item) return;
724
+ item.endTime = Date.now();
725
+ item.duration = item.endTime - item.startTime;
726
+ if (includeResponseBody) {
727
+ const bodyTask = session.send("Network.getResponseBody", { requestId: event.requestId })
728
+ .then((body) => {
729
+ item.responseBody = body.body;
730
+ item.responseBodyBase64Encoded = body.base64Encoded;
731
+ })
732
+ .catch((err) => {
733
+ item.responseBodyError = err.message;
734
+ });
735
+ pendingBodies.push(bodyTask);
736
+ }
737
+ });
738
+
739
+ session.on("Network.loadingFailed", (event) => {
740
+ const item = requests.get(`${targetInfo.id}:${event.requestId}`);
741
+ if (!item) return;
742
+ item.endTime = Date.now();
743
+ item.duration = item.endTime - item.startTime;
744
+ item.errorText = event.errorText || "loadingFailed";
745
+ });
746
+ };
747
+
748
+ const pageSession = await this.page.target().createCDPSession();
749
+ await addSession(pageSession, {
750
+ id: "page",
751
+ type: "page",
752
+ url: this.page.url(),
753
+ matchedBy: "currentPage",
754
+ });
755
+
756
+ const pageTarget = this.page.target();
757
+ const pickCandidates = () => this.browser.targets().filter((target) => {
758
+ if (target === pageTarget) return false;
759
+ if (!["iframe", "page"].includes(target.type())) return false;
760
+ const targetUrl = target.url() || "";
761
+ if (options.targetUrlContains && !targetUrl.includes(options.targetUrlContains)) return false;
762
+ if (targetFrame?.frame && targetUrl && targetFrame.frame.url() && targetUrl !== targetFrame.frame.url() && !targetUrl.includes(targetFrame.frame.url())) {
763
+ return options.includeAllTargets === true;
764
+ }
765
+ return true;
766
+ });
767
+
768
+ const attachOnce = async (candidates) => {
769
+ for (const target of candidates) {
770
+ const session = await target.createCDPSession().catch(() => null);
771
+ if (!session) continue;
772
+ await addSession(session, {
773
+ id: target.url() || `${target.type()}-${targets.length}`,
774
+ type: target.type(),
775
+ url: target.url() || "",
776
+ matchedBy: "browser.targets",
777
+ });
778
+ }
779
+ };
780
+
781
+ // 微前端 wujie/qiankun 时序:iframe target 可能在主页面 ready 后才注册
782
+ // attach=0 时延迟重试,最多 3 次(含首次)
783
+ const maxAttachTries = Number.parseInt(options.attachRetries ?? 3, 10);
784
+ const attachRetryDelay = Number.parseInt(options.attachRetryDelay ?? 1500, 10);
785
+ let attachCandidates = pickCandidates();
786
+ let tries = 1;
787
+ await attachOnce(attachCandidates);
788
+ const attachLog = [{ try: 1, count: attachCandidates.length }];
789
+ while (attachCandidates.length === 0 && tries < maxAttachTries) {
790
+ tries += 1;
791
+ await new Promise((r) => setTimeout(r, attachRetryDelay));
792
+ attachCandidates = pickCandidates();
793
+ attachLog.push({ try: tries, count: attachCandidates.length });
794
+ await attachOnce(attachCandidates);
795
+ }
796
+
797
+ if (options.reload) {
798
+ await this.page.reload({ waitUntil: options.waitUntil || "domcontentloaded", timeout: options.reloadTimeout || 30000 }).catch(() => null);
799
+ }
800
+
801
+ await new Promise((resolve) => setTimeout(resolve, wait));
802
+ await Promise.allSettled(pendingBodies);
803
+
804
+ await Promise.all(sessions.map((session) => session.send("Network.disable").catch(() => null)));
805
+
806
+ const requestList = Array.from(requests.values()).sort((a, b) => a.startTime - b.startTime);
807
+ const title = await this.page.title().catch(() => "");
808
+ const failed = requestList.filter((item) => item.errorText || (item.status && item.status >= 400));
809
+
810
+ return {
811
+ type: "apiCapture",
812
+ page: {
813
+ url: this.page.url(),
814
+ title,
815
+ },
816
+ target: targetFrame ? {
817
+ frameId: targetFrame.frameId,
818
+ frameUrl: targetFrame.frame.url(),
819
+ matchedBy: targetFrame.matchedBy,
820
+ } : null,
821
+ targets,
822
+ requests: requestList,
823
+ summary: {
824
+ total: requestList.length,
825
+ success: requestList.length - failed.length,
826
+ failed: failed.length,
827
+ targets: targets.length,
828
+ attachLog,
829
+ duration: Date.now() - startedAt,
830
+ },
831
+ };
832
+ }
833
+
834
+ assertApi(captureResult, options = {}) {
835
+ if (!captureResult || !Array.isArray(captureResult.requests)) {
836
+ throw new Error("assertApi requires an apiCapture result");
837
+ }
838
+
839
+ const requests = captureResult.requests;
840
+ const expect = options.expect || options;
841
+ const checks = {};
842
+ let passed = true;
843
+
844
+ if (expect.maxFailed !== undefined) {
845
+ const actual = requests.filter((item) => item.errorText || (item.status && item.status >= 400)).length;
846
+ checks.maxFailed = {
847
+ expected: Number.parseInt(expect.maxFailed, 10),
848
+ actual,
849
+ passed: actual <= Number.parseInt(expect.maxFailed, 10),
850
+ };
851
+ if (!checks.maxFailed.passed) passed = false;
852
+ }
853
+
854
+ if (expect.forbidStatusGte !== undefined) {
855
+ const threshold = Number.parseInt(expect.forbidStatusGte, 10);
856
+ const violations = requests
857
+ .filter((item) => item.status && item.status >= threshold)
858
+ .map((item) => ({ url: item.url, method: item.method, status: item.status }));
859
+ checks.forbidStatusGte = {
860
+ expected: threshold,
861
+ violations,
862
+ passed: violations.length === 0,
863
+ };
864
+ if (!checks.forbidStatusGte.passed) passed = false;
865
+ }
866
+
867
+ if (expect.requiredUrls !== undefined) {
868
+ const requiredUrls = Array.isArray(expect.requiredUrls) ? expect.requiredUrls : [expect.requiredUrls];
869
+ checks.requiredUrls = requiredUrls.map((url) => {
870
+ const matched = requests.filter((item) => item.url?.includes(url));
871
+ return {
872
+ url,
873
+ matched: matched.length,
874
+ passed: matched.length > 0,
875
+ };
876
+ });
877
+ if (checks.requiredUrls.some((item) => !item.passed)) passed = false;
878
+ }
879
+
880
+ if (expect.requiredMethods !== undefined) {
881
+ const requiredMethods = Array.isArray(expect.requiredMethods) ? expect.requiredMethods : [expect.requiredMethods];
882
+ checks.requiredMethods = requiredMethods.map((method) => {
883
+ const normalized = String(method).toUpperCase();
884
+ const matched = requests.filter((item) => item.method === normalized);
885
+ return {
886
+ method: normalized,
887
+ matched: matched.length,
888
+ passed: matched.length > 0,
889
+ };
890
+ });
891
+ if (checks.requiredMethods.some((item) => !item.passed)) passed = false;
892
+ }
893
+
894
+ if (expect.minTotal !== undefined) {
895
+ const expected = Number.parseInt(expect.minTotal, 10);
896
+ checks.minTotal = {
897
+ expected,
898
+ actual: requests.length,
899
+ passed: requests.length >= expected,
900
+ };
901
+ if (!checks.minTotal.passed) passed = false;
902
+ }
903
+
904
+ return {
905
+ type: "assertApi",
906
+ passed,
907
+ checks,
908
+ summary: captureResult?.summary || {
909
+ total: requests.length,
910
+ failed: requests.filter((item) => item.errorText || (item.status && item.status >= 400)).length,
911
+ },
912
+ };
913
+ }
914
+
420
915
  async _resolveObserveFrame(options = {}) {
421
916
  const frames = this.page.frames();
422
917
  const mainFrame = this.page.mainFrame();
@@ -613,6 +1108,10 @@ export class BrowserEngine {
613
1108
  case "observe": return this.observe(step);
614
1109
  case "act": return this.act(step);
615
1110
  case "assert": return this.assert(step);
1111
+ case "apiCapture": return this.captureApi(step);
1112
+ case "api-capture": return this.captureApi(step);
1113
+ case "assertApi": return this.assertApi(step.captureResult || context.lastApiCapture, step);
1114
+ case "assert-api": return this.assertApi(step.captureResult || context.lastApiCapture, step);
616
1115
  case "navigate": return this._navigate(step);
617
1116
  case "screenshot": return this._screenshot(step, context);
618
1117
  case "compare": return this._compare(step, context);
@@ -625,6 +1124,8 @@ export class BrowserEngine {
625
1124
  case "findFrame": return this._findFrame(step);
626
1125
  case "loginWait": return this._loginWait(step);
627
1126
  case "aiAssert": return this._aiAssert(step, context);
1127
+ case "runTask": return this.runTask(step, context);
1128
+ case "run-task": return this.runTask(step, context);
628
1129
  default:
629
1130
  throw new Error(`Unknown step type: ${step.type}`);
630
1131
  }
@@ -1039,6 +1540,205 @@ export class BrowserEngine {
1039
1540
  };
1040
1541
  }
1041
1542
 
1543
+ /**
1544
+ * 高层 runner:把"批量探索动态菜单 + 抓接口 + 出报告"打包成一个 step。
1545
+ * 当前仅支持 task='menu-api-scan',对应反馈中"129 路由全覆盖"场景。
1546
+ *
1547
+ * options:
1548
+ * task: "menu-api-scan"
1549
+ * menuApiPattern (string|RegExp 默认 /listUserViewMenus/) — 用作 _discoverMenus 的 lastApiCapture 过滤
1550
+ * menuLimit (number 默认 0=不限) — 仅探索前 N 条
1551
+ * menuPathPrefix (Array<string>) — 仅探索 path 开头匹配的菜单
1552
+ * menuFilter (Array<string>) — 仅探索 name/path 包含任一关键字的菜单
1553
+ * drawerTriggerSelector / drawerOpenSelector / menuScrollContainer / useNativeHover / maxRetryPerStep — 透传 _actMenuPath
1554
+ * apiCaptureOptions (object) — 透传 captureApi
1555
+ * continueOnFail (bool 默认 true)
1556
+ * snapshotEach (bool 默认 false) — 每条菜单后做轻量 observe
1557
+ * screenshotEach (bool 默认 false) — 失败时截图(依赖 context.outputDir)
1558
+ */
1559
+ async runTask(step, context = {}) {
1560
+ const taskName = step.task || step.name || "menu-api-scan";
1561
+ if (taskName !== "menu-api-scan") {
1562
+ return { task: taskName, supported: false, error: `Unsupported task: ${taskName}` };
1563
+ }
1564
+
1565
+ const menus = await this._discoverMenus(step, context);
1566
+ if (!menus.length) {
1567
+ return {
1568
+ task: taskName,
1569
+ discovered: 0,
1570
+ error: "No menus discovered. Run apiCapture targeting menuApiPattern first, or provide step.menus directly.",
1571
+ };
1572
+ }
1573
+
1574
+ const limit = Number.parseInt(step.menuLimit ?? 0, 10);
1575
+ const candidates = limit > 0 ? menus.slice(0, limit) : menus;
1576
+ const continueOnFail = step.continueOnFail !== false;
1577
+ const startedAt = Date.now();
1578
+ const results = [];
1579
+ const byOutcome = { clickFailed: 0, noApi: 0, apiOk: 0, apiFailed: 0 };
1580
+
1581
+ for (let i = 0; i < candidates.length; i += 1) {
1582
+ const menu = candidates[i];
1583
+ const itemStartedAt = Date.now();
1584
+ const item = {
1585
+ index: i,
1586
+ menu: { path: menu.path, name: menu.name, names: menu.names },
1587
+ outcome: null,
1588
+ };
1589
+
1590
+ // 1. 点菜单链路(走 act 顶层入口,让它先 _resolveObserveFrame 再分发到 _actMenuPath)
1591
+ const menuStep = {
1592
+ menuPath: menu.names,
1593
+ drawerTriggerSelector: step.drawerTriggerSelector,
1594
+ drawerOpenSelector: step.drawerOpenSelector,
1595
+ menuScrollContainer: step.menuScrollContainer,
1596
+ useNativeHover: step.useNativeHover,
1597
+ maxRetryPerStep: step.maxRetryPerStep,
1598
+ stepDelay: step.stepDelay,
1599
+ frameContentContains: step.frameContentContains,
1600
+ frameUrlContains: step.frameUrlContains,
1601
+ frameIndex: step.frameIndex,
1602
+ };
1603
+ let menuResult;
1604
+ try {
1605
+ menuResult = await this.act(menuStep);
1606
+ } catch (err) {
1607
+ menuResult = { acted: false, error: err.message };
1608
+ }
1609
+ item.menuResult = menuResult;
1610
+
1611
+ if (!menuResult || menuResult.acted === false) {
1612
+ item.outcome = "clickFailed";
1613
+ byOutcome.clickFailed += 1;
1614
+ if (step.screenshotEach) {
1615
+ try {
1616
+ const shot = await this._screenshot({ filename: `runTask-fail-${i}.png`, fullPage: true }, context);
1617
+ item.screenshot = shot;
1618
+ } catch {
1619
+ // ignore
1620
+ }
1621
+ }
1622
+ results.push({ ...item, durationMs: Date.now() - itemStartedAt });
1623
+ if (!continueOnFail) break;
1624
+ continue;
1625
+ }
1626
+
1627
+ // 2. 抓接口
1628
+ const apiStep = {
1629
+ type: "apiCapture",
1630
+ ...(step.apiCaptureOptions || {}),
1631
+ wait: step.apiCaptureOptions?.wait ?? 4000,
1632
+ };
1633
+ let apiResult;
1634
+ try {
1635
+ apiResult = await this.captureApi(apiStep);
1636
+ } catch (err) {
1637
+ apiResult = { error: err.message, summary: { total: 0, failed: 0 } };
1638
+ }
1639
+ item.api = apiResult;
1640
+
1641
+ const total = apiResult?.summary?.total ?? 0;
1642
+ const failed = apiResult?.summary?.failed ?? 0;
1643
+ if (total === 0) {
1644
+ item.outcome = "noApi";
1645
+ byOutcome.noApi += 1;
1646
+ } else if (failed > 0) {
1647
+ item.outcome = "apiFailed";
1648
+ byOutcome.apiFailed += 1;
1649
+ } else {
1650
+ item.outcome = "apiOk";
1651
+ byOutcome.apiOk += 1;
1652
+ }
1653
+
1654
+ if (step.snapshotEach) {
1655
+ try {
1656
+ item.snapshot = await this.observe({ headingsOnly: true });
1657
+ } catch {
1658
+ // ignore
1659
+ }
1660
+ }
1661
+
1662
+ results.push({ ...item, durationMs: Date.now() - itemStartedAt });
1663
+ }
1664
+
1665
+ return {
1666
+ task: taskName,
1667
+ discovered: menus.length,
1668
+ attempted: results.length,
1669
+ durationMs: Date.now() - startedAt,
1670
+ byOutcome,
1671
+ results,
1672
+ };
1673
+ }
1674
+
1675
+ /**
1676
+ * 从 lastApiCapture 中找菜单接口响应(默认 /listUserViewMenus/),
1677
+ * 用 CDP getResponseBody 拿到 body 后递归拍平。
1678
+ * 兜底:如果 step.menus 已显式传入,直接用之。
1679
+ */
1680
+ async _discoverMenus(step = {}, context = {}) {
1681
+ if (Array.isArray(step.menus) && step.menus.length) {
1682
+ return this._flattenMenuTree(step.menus, step.menuPathPrefix, step.menuFilter);
1683
+ }
1684
+
1685
+ const lastCapture = step.captureResult || context.lastApiCapture;
1686
+ if (!lastCapture?.requests?.length) return [];
1687
+
1688
+ const patternRaw = step.menuApiPattern ?? "listUserViewMenus";
1689
+ const pattern = patternRaw instanceof RegExp ? patternRaw : new RegExp(patternRaw);
1690
+ const candidate = lastCapture.requests.find((r) => pattern.test(r.url || ""));
1691
+ if (!candidate) return [];
1692
+
1693
+ let tree = candidate.responseBody;
1694
+ if (typeof tree === "string") {
1695
+ try { tree = JSON.parse(tree); } catch { return []; }
1696
+ }
1697
+ // 常见包装:{ data: [...] } / { result: { data: [...] } }
1698
+ const list =
1699
+ (Array.isArray(tree) && tree) ||
1700
+ tree?.data ||
1701
+ tree?.result?.data ||
1702
+ tree?.result ||
1703
+ [];
1704
+ return this._flattenMenuTree(list, step.menuPathPrefix, step.menuFilter);
1705
+ }
1706
+
1707
+ /**
1708
+ * 把菜单树拍平成可点击的链路:
1709
+ * { path, name, names: [一级名, 二级名, ...], depth }
1710
+ * children 字段名兼容 children/subMenus/sub。
1711
+ */
1712
+ _flattenMenuTree(tree, pathPrefix, keywords) {
1713
+ const out = [];
1714
+ const prefixes = Array.isArray(pathPrefix) ? pathPrefix : pathPrefix ? [pathPrefix] : null;
1715
+ const kws = Array.isArray(keywords) ? keywords : keywords ? [keywords] : null;
1716
+
1717
+ const visit = (nodes, parentNames) => {
1718
+ if (!Array.isArray(nodes)) return;
1719
+ for (const node of nodes) {
1720
+ if (!node) continue;
1721
+ const name = node.name || node.title || node.label || node.menuName;
1722
+ const path = node.path || node.url || node.route;
1723
+ const names = name ? [...parentNames, name] : parentNames.slice();
1724
+ const children = node.children || node.subMenus || node.sub || node.childs;
1725
+
1726
+ // 叶子节点(无 children 或 children 空)才有意义点击触发请求
1727
+ const isLeaf = !Array.isArray(children) || children.length === 0;
1728
+ if (isLeaf && name && path) {
1729
+ let keep = true;
1730
+ if (prefixes && !prefixes.some((p) => path.startsWith(p))) keep = false;
1731
+ if (keep && kws && !kws.some((k) => name.includes(k) || path.includes(k))) keep = false;
1732
+ if (keep) out.push({ path, name, names, depth: names.length });
1733
+ } else if (Array.isArray(children) && children.length) {
1734
+ visit(children, names);
1735
+ }
1736
+ }
1737
+ };
1738
+ visit(tree, []);
1739
+ return out;
1740
+ }
1741
+
1042
1742
  _resolveSelector(selector) {
1043
1743
  if (!selector) return selector;
1044
1744
  // ~.editKnowledge → [class*="editKnowledge"]