@szc-ft/mcp-szcd-client 0.27.3 → 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.
@@ -269,12 +269,28 @@ export class BrowserEngine {
269
269
  .filter(isCandidate);
270
270
  }
271
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
+
272
281
  function findBySelector(selector) {
273
282
  if (!selector) return null;
274
283
  if (selector.startsWith("text=")) {
275
284
  const expected = selector.slice(5).trim();
276
- return interactiveElements().find((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected)) ||
277
- 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;
278
294
  }
279
295
  if (selector.startsWith("role=")) {
280
296
  const match = selector.match(/^role=([^\[]+)(?:\[name="([^"]+)"\])?/);
@@ -357,6 +373,7 @@ export class BrowserEngine {
357
373
  action,
358
374
  typeText,
359
375
  clear: Boolean(options.clear),
376
+ includeHidden: Boolean(options.includeHidden),
360
377
  });
361
378
 
362
379
  if (!result.found) {
@@ -385,140 +402,207 @@ export class BrowserEngine {
385
402
  ? options.menuPath
386
403
  : String(options.menuPath).split("/").map((item) => item.trim()).filter(Boolean);
387
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);
388
410
 
389
411
  if (menuPath.length === 0) {
390
412
  throw new Error("menuPath is empty");
391
413
  }
392
414
 
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
- }
415
+ const frame = targetFrame.frame;
424
416
 
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
- }
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
+ };
441
432
 
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
- }
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
+ };
448
442
 
449
- function elementSnapshot(el) {
450
- const rect = el.getBoundingClientRect();
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();
451
501
  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"),
502
+ found: true,
503
+ matchedText: top.text,
457
504
  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),
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"),
462
514
  },
463
515
  };
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
- }
516
+ }, text, level, menuPath.length).catch(() => ({ found: false, candidates: [] }));
517
+ };
478
518
 
479
- const el = match.el;
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" };
480
523
  el.scrollIntoView({ block: "center", inline: "center" });
481
524
  const beforeUrl = location.href;
482
525
  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
526
  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,
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",
499
533
  after: {
500
- className: afterClass,
501
- ariaExpanded: afterExpanded,
534
+ className: cls,
535
+ ariaExpanded: el.getAttribute("aria-expanded"),
502
536
  urlChanged: location.href !== beforeUrl,
503
537
  textChanged: (document.body?.innerText || "") !== beforeText,
504
- selected: afterClass.includes("selected") || afterClass.includes("active"),
538
+ selected: cls.includes("selected") || cls.includes("active"),
505
539
  },
506
- });
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);
507
561
  }
508
562
 
509
- return {
510
- found: true,
511
- performed: true,
512
- steps,
513
- currentUrl: location.href,
514
- title: document.title,
515
- };
516
- }, menuPath, waitAfterEach);
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
+ }
517
580
 
518
- if (!result.found) {
519
- throw new Error(`Menu path item not found: ${result.failedAt}`);
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
+ });
520
601
  }
521
602
 
603
+ const currentUrl = this.page.url();
604
+ const title = await this.page.title().catch(() => "");
605
+
522
606
  return {
523
607
  acted: true,
524
608
  action: "menuPath",
@@ -528,9 +612,9 @@ export class BrowserEngine {
528
612
  frameUrl: targetFrame.frame.url(),
529
613
  matchedBy: targetFrame.matchedBy,
530
614
  },
531
- steps: result.steps,
532
- currentUrl: result.currentUrl,
533
- title: result.title,
615
+ steps,
616
+ currentUrl,
617
+ title,
534
618
  };
535
619
  }
536
620
 
@@ -670,7 +754,7 @@ export class BrowserEngine {
670
754
  });
671
755
 
672
756
  const pageTarget = this.page.target();
673
- const attachCandidates = this.browser.targets().filter((target) => {
757
+ const pickCandidates = () => this.browser.targets().filter((target) => {
674
758
  if (target === pageTarget) return false;
675
759
  if (!["iframe", "page"].includes(target.type())) return false;
676
760
  const targetUrl = target.url() || "";
@@ -681,15 +765,33 @@ export class BrowserEngine {
681
765
  return true;
682
766
  });
683
767
 
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
- });
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);
693
795
  }
694
796
 
695
797
  if (options.reload) {
@@ -723,6 +825,7 @@ export class BrowserEngine {
723
825
  success: requestList.length - failed.length,
724
826
  failed: failed.length,
725
827
  targets: targets.length,
828
+ attachLog,
726
829
  duration: Date.now() - startedAt,
727
830
  },
728
831
  };
@@ -1021,6 +1124,8 @@ export class BrowserEngine {
1021
1124
  case "findFrame": return this._findFrame(step);
1022
1125
  case "loginWait": return this._loginWait(step);
1023
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);
1024
1129
  default:
1025
1130
  throw new Error(`Unknown step type: ${step.type}`);
1026
1131
  }
@@ -1435,6 +1540,205 @@ export class BrowserEngine {
1435
1540
  };
1436
1541
  }
1437
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
+
1438
1742
  _resolveSelector(selector) {
1439
1743
  if (!selector) return selector;
1440
1744
  // ~.editKnowledge → [class*="editKnowledge"]