@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.
- package/lib/browser-engine.js +427 -123
- package/local-browser-executor.js +103 -2
- package/opencode-extension/skills/local-browser-test/SKILL.md +69 -0
- package/opencode-extension/skills/local-browser-test/local-browser-executor.cjs +15 -0
- package/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +69 -0
- package/qwen-extension/skills/local-browser-test/local-browser-executor.cjs +15 -0
- package/standard-skill/local-browser-test/SKILL.md +69 -0
- package/standard-skill/local-browser-test/local-browser-executor.cjs +15 -0
- package/opencode-extension/skills/local-browser-test/local-browser-executor-old.cjs +0 -395
- package/qwen-extension/skills/local-browser-test/local-browser-executor-old.cjs +0 -395
package/lib/browser-engine.js
CHANGED
|
@@ -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
|
-
|
|
277
|
-
|
|
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
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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:
|
|
501
|
-
ariaExpanded:
|
|
534
|
+
className: cls,
|
|
535
|
+
ariaExpanded: el.getAttribute("aria-expanded"),
|
|
502
536
|
urlChanged: location.href !== beforeUrl,
|
|
503
537
|
textChanged: (document.body?.innerText || "") !== beforeText,
|
|
504
|
-
selected:
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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
|
|
532
|
-
currentUrl
|
|
533
|
-
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
|
|
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
|
-
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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"]
|