@szc-ft/mcp-szcd-client 0.27.3 → 0.28.0
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/ant-adapter.js +716 -0
- package/lib/browser-engine.js +898 -123
- package/lib/expect.js +392 -0
- package/lib/test-plan.js +48 -0
- package/local-browser-executor.js +172 -2
- package/opencode-extension/skills/local-browser-test/SKILL.md +182 -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 +182 -0
- package/qwen-extension/skills/local-browser-test/local-browser-executor.cjs +15 -0
- package/standard-skill/local-browser-test/SKILL.md +182 -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
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { findChrome, getChromeInstallGuide } from "./chrome-finder.js";
|
|
15
|
+
import { AntAdapter } from "./ant-adapter.js";
|
|
16
|
+
import { createExpect, locator as expectLocator, validation as expectValidation, url as expectUrl, api as expectApi } from "./expect.js";
|
|
15
17
|
|
|
16
18
|
export class BrowserEngine {
|
|
17
19
|
constructor() {
|
|
@@ -20,6 +22,20 @@ export class BrowserEngine {
|
|
|
20
22
|
this.mode = null; // 'connect' | 'launch'
|
|
21
23
|
this.ownedBrowser = false; // launch 模式为 true,决定关闭时是否 kill
|
|
22
24
|
this._activeFrame = null; // 当前活跃 iframe 上下文(微前端场景)
|
|
25
|
+
this._adapter = null; // 懒加载 AntAdapter
|
|
26
|
+
this._lastActWasDropdown = false; // 用于 act 入口自动 closeAllDropdowns
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 获取 / 懒初始化 AntAdapter,支持 componentMeta 注入(来自 szcd MCP 私有组件元数据)
|
|
31
|
+
*/
|
|
32
|
+
_getAdapter(componentMeta) {
|
|
33
|
+
if (!this._adapter) {
|
|
34
|
+
this._adapter = new AntAdapter({ componentMeta: componentMeta || {} });
|
|
35
|
+
} else if (componentMeta) {
|
|
36
|
+
this._adapter.componentMeta = { ...this._adapter.componentMeta, ...componentMeta };
|
|
37
|
+
}
|
|
38
|
+
return this._adapter;
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
/**
|
|
@@ -147,6 +163,7 @@ export class BrowserEngine {
|
|
|
147
163
|
const result = await this._dispatchStep(step, context);
|
|
148
164
|
if (step.type === "apiCapture" || step.type === "api-capture") {
|
|
149
165
|
context.lastApiCapture = result;
|
|
166
|
+
this._lastApiCapture = result; // 同步到 engine,供 _expect 使用
|
|
150
167
|
}
|
|
151
168
|
return {
|
|
152
169
|
...result,
|
|
@@ -217,6 +234,17 @@ export class BrowserEngine {
|
|
|
217
234
|
}
|
|
218
235
|
|
|
219
236
|
async act(options = {}) {
|
|
237
|
+
// 上一次操作是 antSelect/antTreeSelect 等下拉控件 → 先关闭残留浮层(反馈第 3 条)
|
|
238
|
+
// 跨 frame 清理:覆盖主 frame + 子应用 iframe(微前端场景)
|
|
239
|
+
if (this._lastActWasDropdown && !options.skipCloseDropdowns) {
|
|
240
|
+
try {
|
|
241
|
+
await this._closeDropdownsAllFrames(this._activeFrame || this.page.mainFrame());
|
|
242
|
+
this._lastActWasDropdown = false;
|
|
243
|
+
} catch {
|
|
244
|
+
// closeAllDropdowns 失败不影响 act 继续
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
220
248
|
const targetFrame = await this._resolveObserveFrame(options);
|
|
221
249
|
if (options.menuPath) {
|
|
222
250
|
return this._actMenuPath(targetFrame, options);
|
|
@@ -269,12 +297,28 @@ export class BrowserEngine {
|
|
|
269
297
|
.filter(isCandidate);
|
|
270
298
|
}
|
|
271
299
|
|
|
300
|
+
function isVisibleEnabled(el) {
|
|
301
|
+
const rect = el.getBoundingClientRect();
|
|
302
|
+
const style = window.getComputedStyle(el);
|
|
303
|
+
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
304
|
+
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false;
|
|
305
|
+
if (el.disabled || el.getAttribute("aria-disabled") === "true") return false;
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
|
|
272
309
|
function findBySelector(selector) {
|
|
273
310
|
if (!selector) return null;
|
|
274
311
|
if (selector.startsWith("text=")) {
|
|
275
312
|
const expected = selector.slice(5).trim();
|
|
276
|
-
|
|
277
|
-
|
|
313
|
+
// 默认仅匹配 visible+enabled 元素,避免命中 ant-select 的隐藏 input、收起菜单里的 li
|
|
314
|
+
// 需要包含隐藏元素时显式 includeHidden=true
|
|
315
|
+
const includeHidden = actOptions.includeHidden === true;
|
|
316
|
+
const filter = (el) => includeHidden || isVisibleEnabled(el);
|
|
317
|
+
const interactive = interactiveElements().filter(filter);
|
|
318
|
+
let hit = interactive.find((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected));
|
|
319
|
+
if (hit) return hit;
|
|
320
|
+
const all = Array.from(document.querySelectorAll("*")).filter(filter);
|
|
321
|
+
return all.find((el) => getText(el).includes(expected)) || null;
|
|
278
322
|
}
|
|
279
323
|
if (selector.startsWith("role=")) {
|
|
280
324
|
const match = selector.match(/^role=([^\[]+)(?:\[name="([^"]+)"\])?/);
|
|
@@ -357,6 +401,7 @@ export class BrowserEngine {
|
|
|
357
401
|
action,
|
|
358
402
|
typeText,
|
|
359
403
|
clear: Boolean(options.clear),
|
|
404
|
+
includeHidden: Boolean(options.includeHidden),
|
|
360
405
|
});
|
|
361
406
|
|
|
362
407
|
if (!result.found) {
|
|
@@ -385,140 +430,207 @@ export class BrowserEngine {
|
|
|
385
430
|
? options.menuPath
|
|
386
431
|
: String(options.menuPath).split("/").map((item) => item.trim()).filter(Boolean);
|
|
387
432
|
const waitAfterEach = Number.parseInt(options.waitAfterEach ?? 300, 10);
|
|
433
|
+
const drawerTriggerSelector = options.drawerTriggerSelector || null;
|
|
434
|
+
const drawerOpenSelector = options.drawerOpenSelector || ".ant-drawer-open, .ant-drawer.ant-drawer-open";
|
|
435
|
+
const scrollContainerSelector = options.menuScrollContainer || null;
|
|
436
|
+
const useNativeHover = options.useNativeHover !== false; // 默认开
|
|
437
|
+
const maxRetryPerStep = Number.parseInt(options.maxRetryPerStep ?? 2, 10);
|
|
388
438
|
|
|
389
439
|
if (menuPath.length === 0) {
|
|
390
440
|
throw new Error("menuPath is empty");
|
|
391
441
|
}
|
|
392
442
|
|
|
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
|
-
}
|
|
443
|
+
const frame = targetFrame.frame;
|
|
401
444
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
}
|
|
445
|
+
const ensureDrawerOpen = async () => {
|
|
446
|
+
if (!drawerTriggerSelector) return { ensured: false, reason: "no drawer" };
|
|
447
|
+
const isOpen = await frame.evaluate((sel) => !!document.querySelector(sel), drawerOpenSelector).catch(() => false);
|
|
448
|
+
if (isOpen) return { ensured: true, reason: "already open" };
|
|
449
|
+
const triggered = await frame.evaluate((sel) => {
|
|
450
|
+
const el = document.querySelector(sel);
|
|
451
|
+
if (!el) return false;
|
|
452
|
+
el.scrollIntoView({ block: "center", inline: "center" });
|
|
453
|
+
el.click();
|
|
454
|
+
return true;
|
|
455
|
+
}, drawerTriggerSelector).catch(() => false);
|
|
456
|
+
if (!triggered) return { ensured: false, reason: "trigger not found" };
|
|
457
|
+
await new Promise((r) => setTimeout(r, waitAfterEach));
|
|
458
|
+
return { ensured: true, reason: "clicked trigger" };
|
|
459
|
+
};
|
|
441
460
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
461
|
+
const scrollContainerOnce = async () => {
|
|
462
|
+
if (!scrollContainerSelector) return false;
|
|
463
|
+
return frame.evaluate((sel) => {
|
|
464
|
+
const c = document.querySelector(sel);
|
|
465
|
+
if (!c) return false;
|
|
466
|
+
c.scrollTop = Math.min(c.scrollTop + Math.max(c.clientHeight - 40, 200), c.scrollHeight);
|
|
467
|
+
return true;
|
|
468
|
+
}, scrollContainerSelector).catch(() => false);
|
|
469
|
+
};
|
|
448
470
|
|
|
449
|
-
|
|
450
|
-
|
|
471
|
+
const findStep = async (text, level) => {
|
|
472
|
+
return frame.evaluate((expectedText, lvl, totalLevels) => {
|
|
473
|
+
function textOf(el) {
|
|
474
|
+
return (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim();
|
|
475
|
+
}
|
|
476
|
+
function classText(el) {
|
|
477
|
+
return typeof el.className === "string" ? el.className : "";
|
|
478
|
+
}
|
|
479
|
+
function isVisible(el) {
|
|
480
|
+
const rect = el.getBoundingClientRect();
|
|
481
|
+
const style = window.getComputedStyle(el);
|
|
482
|
+
return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
|
|
483
|
+
}
|
|
484
|
+
function menuCandidates() {
|
|
485
|
+
return Array.from(document.querySelectorAll([
|
|
486
|
+
"[role='menuitem']",
|
|
487
|
+
".ant-menu-item",
|
|
488
|
+
".ant-menu-submenu-title",
|
|
489
|
+
"li[class*='menu']",
|
|
490
|
+
"a",
|
|
491
|
+
"button",
|
|
492
|
+
"[class*='menu-item']",
|
|
493
|
+
"[class*='submenu']",
|
|
494
|
+
].join(","))).filter(isVisible);
|
|
495
|
+
}
|
|
496
|
+
function score(el) {
|
|
497
|
+
const actual = textOf(el);
|
|
498
|
+
if (!actual) return -1;
|
|
499
|
+
let s = -1;
|
|
500
|
+
if (actual === expectedText) s = 100;
|
|
501
|
+
else if (actual.includes(expectedText)) s = 70;
|
|
502
|
+
else if (expectedText.includes(actual)) s = 50;
|
|
503
|
+
if (s < 0) return -1;
|
|
504
|
+
const cls = classText(el);
|
|
505
|
+
const role = el.getAttribute("role") || "";
|
|
506
|
+
if (role === "menuitem") s += 10;
|
|
507
|
+
if (cls.includes("ant-menu-submenu-title")) s += lvl < totalLevels - 1 ? 20 : -5;
|
|
508
|
+
if (cls.includes("ant-menu-item")) s += lvl === totalLevels - 1 ? 20 : 0;
|
|
509
|
+
if (cls.includes("ant-menu-item-selected")) s += 5;
|
|
510
|
+
return s;
|
|
511
|
+
}
|
|
512
|
+
const ranked = menuCandidates()
|
|
513
|
+
.map((el) => ({ el, s: score(el), text: textOf(el) }))
|
|
514
|
+
.filter((x) => x.s >= 0)
|
|
515
|
+
.sort((a, b) => b.s - a.s);
|
|
516
|
+
const top = ranked[0];
|
|
517
|
+
if (!top) {
|
|
518
|
+
return {
|
|
519
|
+
found: false,
|
|
520
|
+
candidates: menuCandidates().slice(0, 8).map((el) => ({
|
|
521
|
+
text: textOf(el).slice(0, 80),
|
|
522
|
+
className: classText(el).slice(0, 120),
|
|
523
|
+
})),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
// 用稳定 attr 标记,避免后续 click 时 ranking 漂移
|
|
527
|
+
top.el.setAttribute("data-szcd-menu-target", "1");
|
|
528
|
+
const rect = top.el.getBoundingClientRect();
|
|
451
529
|
return {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
role: el.getAttribute("role") || null,
|
|
455
|
-
className: classText(el),
|
|
456
|
-
ariaExpanded: el.getAttribute("aria-expanded"),
|
|
530
|
+
found: true,
|
|
531
|
+
matchedText: top.text,
|
|
457
532
|
bbox: {
|
|
458
|
-
x: Math.round(rect.x),
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
533
|
+
x: Math.round(rect.x), y: Math.round(rect.y),
|
|
534
|
+
width: Math.round(rect.width), height: Math.round(rect.height),
|
|
535
|
+
},
|
|
536
|
+
snapshot: {
|
|
537
|
+
tagName: top.el.tagName,
|
|
538
|
+
text: textOf(top.el).slice(0, 120),
|
|
539
|
+
role: top.el.getAttribute("role") || null,
|
|
540
|
+
className: classText(top.el),
|
|
541
|
+
ariaExpanded: top.el.getAttribute("aria-expanded"),
|
|
462
542
|
},
|
|
463
543
|
};
|
|
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
|
-
}
|
|
544
|
+
}, text, level, menuPath.length).catch(() => ({ found: false, candidates: [] }));
|
|
545
|
+
};
|
|
478
546
|
|
|
479
|
-
|
|
547
|
+
const clickStep = async (isLast) => {
|
|
548
|
+
return frame.evaluate(async (waitMs, last) => {
|
|
549
|
+
const el = document.querySelector("[data-szcd-menu-target='1']");
|
|
550
|
+
if (!el) return { performed: false, error: "marker lost" };
|
|
480
551
|
el.scrollIntoView({ block: "center", inline: "center" });
|
|
481
552
|
const beforeUrl = location.href;
|
|
482
553
|
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
554
|
el.click();
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
action: isLast ? "click" : "expand",
|
|
496
|
-
status: "PASS",
|
|
497
|
-
matchedText: match.text,
|
|
498
|
-
before,
|
|
555
|
+
el.removeAttribute("data-szcd-menu-target");
|
|
556
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
557
|
+
const cls = typeof el.className === "string" ? el.className : "";
|
|
558
|
+
return {
|
|
559
|
+
performed: true,
|
|
560
|
+
action: last ? "click" : "expand",
|
|
499
561
|
after: {
|
|
500
|
-
className:
|
|
501
|
-
ariaExpanded:
|
|
562
|
+
className: cls,
|
|
563
|
+
ariaExpanded: el.getAttribute("aria-expanded"),
|
|
502
564
|
urlChanged: location.href !== beforeUrl,
|
|
503
565
|
textChanged: (document.body?.innerText || "") !== beforeText,
|
|
504
|
-
selected:
|
|
566
|
+
selected: cls.includes("selected") || cls.includes("active"),
|
|
505
567
|
},
|
|
506
|
-
}
|
|
568
|
+
};
|
|
569
|
+
}, waitAfterEach, isLast);
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const steps = [];
|
|
573
|
+
|
|
574
|
+
for (let i = 0; i < menuPath.length; i++) {
|
|
575
|
+
const text = menuPath[i];
|
|
576
|
+
const isLast = i === menuPath.length - 1;
|
|
577
|
+
|
|
578
|
+
// 抽屉式菜单:每级前都先确认抽屉是开的
|
|
579
|
+
const drawerState = await ensureDrawerOpen();
|
|
580
|
+
|
|
581
|
+
let match = await findStep(text, i);
|
|
582
|
+
let attempts = 0;
|
|
583
|
+
while (!match.found && attempts < maxRetryPerStep) {
|
|
584
|
+
attempts += 1;
|
|
585
|
+
// 滚动一次再找
|
|
586
|
+
const scrolled = await scrollContainerOnce();
|
|
587
|
+
if (!scrolled) await new Promise((r) => setTimeout(r, waitAfterEach));
|
|
588
|
+
match = await findStep(text, i);
|
|
507
589
|
}
|
|
508
590
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
591
|
+
if (!match.found) {
|
|
592
|
+
return {
|
|
593
|
+
acted: false,
|
|
594
|
+
action: "menuPath",
|
|
595
|
+
menuPath,
|
|
596
|
+
target: {
|
|
597
|
+
frameId: targetFrame.frameId,
|
|
598
|
+
frameUrl: targetFrame.frame.url(),
|
|
599
|
+
matchedBy: targetFrame.matchedBy,
|
|
600
|
+
},
|
|
601
|
+
failedAt: text,
|
|
602
|
+
failedAtIndex: i,
|
|
603
|
+
candidates: match.candidates || [],
|
|
604
|
+
drawerState,
|
|
605
|
+
steps,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
517
608
|
|
|
518
|
-
|
|
519
|
-
|
|
609
|
+
// native hover(puppeteer frame.hover),优于 dispatchEvent
|
|
610
|
+
if (useNativeHover && typeof frame.hover === "function") {
|
|
611
|
+
try {
|
|
612
|
+
await frame.hover("[data-szcd-menu-target='1']");
|
|
613
|
+
} catch {
|
|
614
|
+
// hover 失败不致命,继续 click
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const clickRes = await clickStep(isLast);
|
|
619
|
+
steps.push({
|
|
620
|
+
text,
|
|
621
|
+
action: clickRes.action || (isLast ? "click" : "expand"),
|
|
622
|
+
status: clickRes.performed ? "PASS" : "FAIL",
|
|
623
|
+
matchedText: match.matchedText,
|
|
624
|
+
before: match.snapshot,
|
|
625
|
+
after: clickRes.after || null,
|
|
626
|
+
drawerEnsured: drawerState.ensured,
|
|
627
|
+
retries: attempts,
|
|
628
|
+
});
|
|
520
629
|
}
|
|
521
630
|
|
|
631
|
+
const currentUrl = this.page.url();
|
|
632
|
+
const title = await this.page.title().catch(() => "");
|
|
633
|
+
|
|
522
634
|
return {
|
|
523
635
|
acted: true,
|
|
524
636
|
action: "menuPath",
|
|
@@ -528,9 +640,9 @@ export class BrowserEngine {
|
|
|
528
640
|
frameUrl: targetFrame.frame.url(),
|
|
529
641
|
matchedBy: targetFrame.matchedBy,
|
|
530
642
|
},
|
|
531
|
-
steps
|
|
532
|
-
currentUrl
|
|
533
|
-
title
|
|
643
|
+
steps,
|
|
644
|
+
currentUrl,
|
|
645
|
+
title,
|
|
534
646
|
};
|
|
535
647
|
}
|
|
536
648
|
|
|
@@ -603,12 +715,15 @@ export class BrowserEngine {
|
|
|
603
715
|
session.on("Network.requestWillBeSent", (event) => {
|
|
604
716
|
const request = event.request || {};
|
|
605
717
|
if (!matchesUrl(request.url) || !matchesMethod(request.method)) return;
|
|
718
|
+
// 为每条请求记录 frameUrl:关键的是框架帧的 URL,不是发起者 iframe 的 document
|
|
719
|
+
// event.frameId / event.loaderId 可关联到 Page.frameNavigated 事件
|
|
606
720
|
requests.set(`${targetInfo.id}:${event.requestId}`, {
|
|
607
721
|
id: event.requestId,
|
|
608
722
|
targetId: targetInfo.id,
|
|
609
723
|
targetType: targetInfo.type,
|
|
610
724
|
targetUrl: targetInfo.url,
|
|
611
725
|
frameId: event.frameId,
|
|
726
|
+
frameUrl: targetInfo.url, // 请求来源的 frame URL
|
|
612
727
|
url: request.url,
|
|
613
728
|
method: request.method,
|
|
614
729
|
requestHeaders: normalizeHeaders(request.headers),
|
|
@@ -670,7 +785,7 @@ export class BrowserEngine {
|
|
|
670
785
|
});
|
|
671
786
|
|
|
672
787
|
const pageTarget = this.page.target();
|
|
673
|
-
const
|
|
788
|
+
const pickCandidates = () => this.browser.targets().filter((target) => {
|
|
674
789
|
if (target === pageTarget) return false;
|
|
675
790
|
if (!["iframe", "page"].includes(target.type())) return false;
|
|
676
791
|
const targetUrl = target.url() || "";
|
|
@@ -681,15 +796,33 @@ export class BrowserEngine {
|
|
|
681
796
|
return true;
|
|
682
797
|
});
|
|
683
798
|
|
|
684
|
-
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
799
|
+
const attachOnce = async (candidates) => {
|
|
800
|
+
for (const target of candidates) {
|
|
801
|
+
const session = await target.createCDPSession().catch(() => null);
|
|
802
|
+
if (!session) continue;
|
|
803
|
+
await addSession(session, {
|
|
804
|
+
id: target.url() || `${target.type()}-${targets.length}`,
|
|
805
|
+
type: target.type(),
|
|
806
|
+
url: target.url() || "",
|
|
807
|
+
matchedBy: "browser.targets",
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
// 微前端 wujie/qiankun 时序:iframe target 可能在主页面 ready 后才注册
|
|
813
|
+
// attach=0 时延迟重试,最多 3 次(含首次)
|
|
814
|
+
const maxAttachTries = Number.parseInt(options.attachRetries ?? 3, 10);
|
|
815
|
+
const attachRetryDelay = Number.parseInt(options.attachRetryDelay ?? 1500, 10);
|
|
816
|
+
let attachCandidates = pickCandidates();
|
|
817
|
+
let tries = 1;
|
|
818
|
+
await attachOnce(attachCandidates);
|
|
819
|
+
const attachLog = [{ try: 1, count: attachCandidates.length }];
|
|
820
|
+
while (attachCandidates.length === 0 && tries < maxAttachTries) {
|
|
821
|
+
tries += 1;
|
|
822
|
+
await new Promise((r) => setTimeout(r, attachRetryDelay));
|
|
823
|
+
attachCandidates = pickCandidates();
|
|
824
|
+
attachLog.push({ try: tries, count: attachCandidates.length });
|
|
825
|
+
await attachOnce(attachCandidates);
|
|
693
826
|
}
|
|
694
827
|
|
|
695
828
|
if (options.reload) {
|
|
@@ -723,6 +856,7 @@ export class BrowserEngine {
|
|
|
723
856
|
success: requestList.length - failed.length,
|
|
724
857
|
failed: failed.length,
|
|
725
858
|
targets: targets.length,
|
|
859
|
+
attachLog,
|
|
726
860
|
duration: Date.now() - startedAt,
|
|
727
861
|
},
|
|
728
862
|
};
|
|
@@ -1021,11 +1155,311 @@ export class BrowserEngine {
|
|
|
1021
1155
|
case "findFrame": return this._findFrame(step);
|
|
1022
1156
|
case "loginWait": return this._loginWait(step);
|
|
1023
1157
|
case "aiAssert": return this._aiAssert(step, context);
|
|
1158
|
+
case "runTask": return this.runTask(step, context);
|
|
1159
|
+
case "run-task": return this.runTask(step, context);
|
|
1160
|
+
// ===== Ant Design 适配层(6/22 反馈落地) =====
|
|
1161
|
+
case "antSelect": return this._antSelect(step);
|
|
1162
|
+
case "antTreeSelect":return this._antTreeSelect(step);
|
|
1163
|
+
case "antInput": return this._antInput(step);
|
|
1164
|
+
case "antRadio": return this._antRadio(step);
|
|
1165
|
+
case "antCheckbox": return this._antCheckbox(step);
|
|
1166
|
+
case "antSwitch": return this._antSwitch(step);
|
|
1167
|
+
case "antDatePicker":return this._antDatePicker(step);
|
|
1168
|
+
case "antCascader": return this._antCascader(step);
|
|
1169
|
+
case "antUpload": return this._antUpload(step);
|
|
1170
|
+
case "formFill": return this._formFill(step);
|
|
1171
|
+
case "closeAllDropdowns": return this._closeAllDropdowns(step);
|
|
1172
|
+
// ===== 语义断言层 =====
|
|
1173
|
+
case "expect": return this._expect(step);
|
|
1024
1174
|
default:
|
|
1025
1175
|
throw new Error(`Unknown step type: ${step.type}`);
|
|
1026
1176
|
}
|
|
1027
1177
|
}
|
|
1028
1178
|
|
|
1179
|
+
/**
|
|
1180
|
+
* 解析目标 frame,优先级(微前端场景兼容):
|
|
1181
|
+
* 1. step 显式传 frameContentContains / frameUrlContains / frameIndex → 用 _resolveObserveFrame
|
|
1182
|
+
* 2. 已有 _activeFrame(来自 setup.findFrame / 上一步 findFrame)→ 直接复用
|
|
1183
|
+
* 3. 否则回退到 mainFrame
|
|
1184
|
+
*
|
|
1185
|
+
* 同时做"frame 探活 + 自动召回":如果 _activeFrame 因子应用卸载而失效,
|
|
1186
|
+
* 自动清空并回退到 mainFrame,避免无限挂在已销毁的 frame 上。
|
|
1187
|
+
*
|
|
1188
|
+
* @returns {Promise<{frame, frameId, matchedBy, frameRecovered?}>} 含 frameRecovered 标志
|
|
1189
|
+
*/
|
|
1190
|
+
async _getTargetFrame(step) {
|
|
1191
|
+
const hasExplicitFrame = step && (step.frameContentContains || step.frameUrlContains || step.frameIndex !== undefined);
|
|
1192
|
+
|
|
1193
|
+
// 路径 1:显式指定 frame → 总是用 _resolveObserveFrame
|
|
1194
|
+
if (hasExplicitFrame) {
|
|
1195
|
+
const resolved = await this._resolveObserveFrame(step);
|
|
1196
|
+
return { frame: resolved.frame, frameId: resolved.frameId, matchedBy: resolved.matchedBy, frameRecovered: false };
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// 路径 2:复用 _activeFrame(微前端场景的核心优化)
|
|
1200
|
+
if (this._activeFrame) {
|
|
1201
|
+
// 探活:确认 frame 还能 evaluate(未被子应用卸载)
|
|
1202
|
+
const alive = await this._activeFrame.evaluate(() => true).catch(() => false);
|
|
1203
|
+
if (alive) {
|
|
1204
|
+
return { frame: this._activeFrame, frameId: "active", matchedBy: "_activeFrame", frameRecovered: false };
|
|
1205
|
+
}
|
|
1206
|
+
// 失活:清空 _activeFrame,召回到 mainFrame
|
|
1207
|
+
this._activeFrame = null;
|
|
1208
|
+
return { frame: this.page.mainFrame(), frameId: "main", matchedBy: "_activeFrame failed, fallback to main", frameRecovered: true };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// 路径 3:mainFrame
|
|
1212
|
+
const resolved = await this._resolveObserveFrame(step || {});
|
|
1213
|
+
return { frame: resolved.frame, frameId: resolved.frameId, matchedBy: resolved.matchedBy, frameRecovered: false };
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
async _antSelect(step) {
|
|
1217
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1218
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1219
|
+
let result = await adapter.select(frame, step.field || step.label, step.option || step.value, {
|
|
1220
|
+
mode: step.mode,
|
|
1221
|
+
timeout: step.timeout,
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
// 微前端跨 frame 浮层兜底:
|
|
1225
|
+
// 如果当前 frame 未找到浮层选项(getPopupContainer 指向主文档),尝试主 frame
|
|
1226
|
+
if (!result.acted && result.reason?.includes?.("not found in dropdown")) {
|
|
1227
|
+
const mainFrame = this.page.mainFrame();
|
|
1228
|
+
if (mainFrame !== frame) {
|
|
1229
|
+
const retry = await adapter.select(mainFrame, step.field || step.label, step.option || step.value, {
|
|
1230
|
+
mode: step.mode,
|
|
1231
|
+
timeout: step.timeout,
|
|
1232
|
+
});
|
|
1233
|
+
if (retry.acted) {
|
|
1234
|
+
result = retry;
|
|
1235
|
+
result._dropdownFrame = "main";
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
this._lastActWasDropdown = true;
|
|
1241
|
+
return { type: "antSelect", passed: result.acted && result.verified !== false, ...result };
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async _antTreeSelect(step) {
|
|
1245
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1246
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1247
|
+
let result = await adapter.treeSelect(frame, step.field || step.label, step.path || step.value, {
|
|
1248
|
+
timeout: step.timeout,
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
// 微前端跨 frame 浮层兜底:同上
|
|
1252
|
+
if (!result.acted && result.reason?.includes?.("node")) {
|
|
1253
|
+
const mainFrame = this.page.mainFrame();
|
|
1254
|
+
if (mainFrame !== frame) {
|
|
1255
|
+
const retry = await adapter.treeSelect(mainFrame, step.field || step.label, step.path || step.value, { timeout: step.timeout });
|
|
1256
|
+
if (retry.acted) {
|
|
1257
|
+
result = retry;
|
|
1258
|
+
result._dropdownFrame = "main";
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
this._lastActWasDropdown = true;
|
|
1264
|
+
return { type: "antTreeSelect", passed: result.acted && result.verified !== false, ...result };
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
async _antInput(step) {
|
|
1268
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1269
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1270
|
+
const target = step.placeholder
|
|
1271
|
+
? { placeholder: step.placeholder }
|
|
1272
|
+
: step.selector
|
|
1273
|
+
? { selector: step.selector }
|
|
1274
|
+
: { label: step.field || step.label };
|
|
1275
|
+
const result = await adapter.input(frame, target, step.value, { clear: step.clear });
|
|
1276
|
+
return { type: "antInput", passed: result.acted, ...result };
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
async _antRadio(step) {
|
|
1280
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1281
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1282
|
+
const result = await adapter.radio(frame, step.field || step.label, step.option || step.value);
|
|
1283
|
+
return { type: "antRadio", passed: result.acted && result.verified, ...result };
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
async _antCheckbox(step) {
|
|
1287
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1288
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1289
|
+
const result = await adapter.checkbox(frame, step.field || step.label, step.option, step.checked !== false);
|
|
1290
|
+
return { type: "antCheckbox", passed: result.acted && result.verified, ...result };
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async _antSwitch(step) {
|
|
1294
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1295
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1296
|
+
const result = await adapter.switch(frame, step.field || step.label, step.on !== false);
|
|
1297
|
+
return { type: "antSwitch", passed: result.acted && result.verified, ...result };
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
async _antDatePicker(step) {
|
|
1301
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1302
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1303
|
+
const result = await adapter.datePicker(frame, step.field || step.label, step.value);
|
|
1304
|
+
this._lastActWasDropdown = true;
|
|
1305
|
+
return { type: "antDatePicker", passed: result.acted, ...result };
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
async _antCascader(step) {
|
|
1309
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1310
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1311
|
+
const result = await adapter.cascader(frame, step.field || step.label, step.path || step.value, { timeout: step.timeout });
|
|
1312
|
+
this._lastActWasDropdown = true;
|
|
1313
|
+
return { type: "antCascader", passed: result.acted, ...result };
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async _antUpload(step) {
|
|
1317
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1318
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1319
|
+
const result = await adapter.upload(frame, step.field || step.label, step.filePath || step.value);
|
|
1320
|
+
return { type: "antUpload", passed: result.acted, ...result };
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
async _formFill(step) {
|
|
1324
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1325
|
+
const adapter = this._getAdapter(step.componentMeta);
|
|
1326
|
+
const result = await adapter.formFill(frame, step.fields || [], { stepDelay: step.stepDelay });
|
|
1327
|
+
const allOk = result.summary.failed === 0;
|
|
1328
|
+
// 校验:可选地立即跑表单校验
|
|
1329
|
+
if (step.validateAfter) {
|
|
1330
|
+
const exp = createExpect({ frame, page: this.page });
|
|
1331
|
+
const validation = await exp.validation().toPass({ timeout: step.validationTimeout ?? 2000 });
|
|
1332
|
+
return {
|
|
1333
|
+
type: "formFill",
|
|
1334
|
+
passed: allOk && validation.passed,
|
|
1335
|
+
...result,
|
|
1336
|
+
validation,
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
return { type: "formFill", passed: allOk, ...result };
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
async _closeAllDropdowns(step) {
|
|
1343
|
+
const { frame } = await this._getTargetFrame(step || {});
|
|
1344
|
+
const adapter = this._getAdapter();
|
|
1345
|
+
// 微前端场景:同时清理主 frame 和所有子 frame 的浮层(修复 2 配套)
|
|
1346
|
+
const result = await this._closeDropdownsAllFrames(frame);
|
|
1347
|
+
this._lastActWasDropdown = false;
|
|
1348
|
+
return { type: "closeAllDropdowns", passed: true, ...result };
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* 修复 2:跨 frame 浮层检测 — Ant 浮层可能渲染在子应用 iframe 内或父应用 document.body
|
|
1353
|
+
* 同时清理当前 frame + 主 frame + 所有可访问子 frame 的浮层
|
|
1354
|
+
*/
|
|
1355
|
+
async _closeDropdownsAllFrames(currentFrame) {
|
|
1356
|
+
const adapter = this._getAdapter();
|
|
1357
|
+
const mainFrame = this.page.mainFrame();
|
|
1358
|
+
const allFrames = this.page.frames();
|
|
1359
|
+
const closed = { perFrame: [], total: 0 };
|
|
1360
|
+
|
|
1361
|
+
// 优先清理当前 frame
|
|
1362
|
+
const framesToClose = new Set([currentFrame, mainFrame, ...allFrames]);
|
|
1363
|
+
for (const frame of framesToClose) {
|
|
1364
|
+
try {
|
|
1365
|
+
// 探活
|
|
1366
|
+
const alive = await frame.evaluate(() => true).catch(() => false);
|
|
1367
|
+
if (!alive) continue;
|
|
1368
|
+
const r = await adapter.closeAllDropdowns(frame);
|
|
1369
|
+
if (r?.closed) {
|
|
1370
|
+
closed.total += r.closed;
|
|
1371
|
+
closed.perFrame.push({ frameUrl: frame.url().slice(0, 100), closed: r.closed });
|
|
1372
|
+
}
|
|
1373
|
+
} catch {
|
|
1374
|
+
// 跨域 iframe 无法 evaluate,跳过
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
return closed;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* 通用 expect 断言:根据 step.assertion 路由到 expect.js 的不同断言方法
|
|
1382
|
+
*
|
|
1383
|
+
* 支持的 assertion:
|
|
1384
|
+
* - "locator.toBeVisible" / "toBeHidden" / "toBeEnabled" / "toHaveText" / "toHaveValue" / "toHaveCount" / "toHaveAttribute"
|
|
1385
|
+
* - "validation.toPass" / "validation.toHaveError"
|
|
1386
|
+
* - "url.toContain" / "url.toMatch"
|
|
1387
|
+
* - "api.toAllPass" / "api.toInclude" / "api.toHaveCount"
|
|
1388
|
+
*/
|
|
1389
|
+
async _expect(step) {
|
|
1390
|
+
const assertion = step.assertion;
|
|
1391
|
+
const opts = { timeout: step.timeout, interval: step.interval };
|
|
1392
|
+
|
|
1393
|
+
if (!assertion) throw new Error("expect step requires assertion field");
|
|
1394
|
+
|
|
1395
|
+
// locator.* 系列
|
|
1396
|
+
if (assertion.startsWith("locator.") || assertion.startsWith("toBe") || assertion.startsWith("toHave")) {
|
|
1397
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1398
|
+
const loc = expectLocator(frame, step.selector);
|
|
1399
|
+
const method = assertion.replace(/^locator\./, "");
|
|
1400
|
+
if (typeof loc[method] !== "function") {
|
|
1401
|
+
throw new Error(`Unknown locator assertion: ${method}`);
|
|
1402
|
+
}
|
|
1403
|
+
// toHaveText/toHaveValue/toHaveAttribute/toHaveCount 需要 expected 参数
|
|
1404
|
+
const expected = step.expected ?? step.value ?? step.text ?? step.count;
|
|
1405
|
+
const r = method === "toHaveAttribute"
|
|
1406
|
+
? await loc.toHaveAttribute(step.name, expected, opts)
|
|
1407
|
+
: (expected !== undefined ? await loc[method](expected, opts) : await loc[method](opts));
|
|
1408
|
+
return { type: "expect", ...r };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// validation.*
|
|
1412
|
+
if (assertion.startsWith("validation.")) {
|
|
1413
|
+
const { frame } = await this._getTargetFrame(step);
|
|
1414
|
+
const val = expectValidation(frame);
|
|
1415
|
+
const method = assertion.replace("validation.", "");
|
|
1416
|
+
const r = method === "toHaveError"
|
|
1417
|
+
? await val.toHaveError(step.field || step.message, opts)
|
|
1418
|
+
: await val.toPass(opts);
|
|
1419
|
+
return { type: "expect", ...r };
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// url.*
|
|
1423
|
+
if (assertion.startsWith("url.")) {
|
|
1424
|
+
const u = expectUrl(this.page);
|
|
1425
|
+
const method = assertion.replace("url.", "");
|
|
1426
|
+
const r = method === "toMatch"
|
|
1427
|
+
? await u.toMatch(step.regex || step.pattern, opts)
|
|
1428
|
+
: await u.toContain(step.value || step.contains, opts);
|
|
1429
|
+
return { type: "expect", ...r };
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// api.*
|
|
1433
|
+
if (assertion.startsWith("api.")) {
|
|
1434
|
+
const capture = step.captureResult || this._lastApiCapture;
|
|
1435
|
+
// 微前端:支持按 frame 范围过滤请求
|
|
1436
|
+
// scope: "all"(默认,全 page)/ "current-frame"(仅 _activeFrame 同源)
|
|
1437
|
+
let frameUrlContains = step.frameUrlContains;
|
|
1438
|
+
if (!frameUrlContains && step.scope === "current-frame" && this._activeFrame) {
|
|
1439
|
+
const url = this._activeFrame.url();
|
|
1440
|
+
// blob: URL(wujie 子应用)取冒号后面的部分;http(s): URL 取 origin
|
|
1441
|
+
if (url.startsWith("blob:")) {
|
|
1442
|
+
try { frameUrlContains = new URL(url.slice(5)).origin; } catch {}
|
|
1443
|
+
} else {
|
|
1444
|
+
try { frameUrlContains = new URL(url).origin; } catch {}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
const a = expectApi(capture, { scope: step.scope || "all", frameUrlContains });
|
|
1448
|
+
const method = assertion.replace("api.", "");
|
|
1449
|
+
if (typeof a[method] !== "function") {
|
|
1450
|
+
throw new Error(`Unknown api assertion: ${method}`);
|
|
1451
|
+
}
|
|
1452
|
+
const r = method === "toInclude"
|
|
1453
|
+
? a.toInclude(step.urlPattern || step.pattern)
|
|
1454
|
+
: method === "toHaveCount"
|
|
1455
|
+
? a.toHaveCount(step.expected ?? step.count)
|
|
1456
|
+
: a[method]();
|
|
1457
|
+
return { type: "expect", ...r };
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
throw new Error(`Unknown expect assertion: ${assertion}`);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1029
1463
|
async _navigate(step) {
|
|
1030
1464
|
const start = Date.now();
|
|
1031
1465
|
// 微前端/SSO 重定向链场景:用 domcontentloaded 避免 page 对象被销毁
|
|
@@ -1435,6 +1869,205 @@ export class BrowserEngine {
|
|
|
1435
1869
|
};
|
|
1436
1870
|
}
|
|
1437
1871
|
|
|
1872
|
+
/**
|
|
1873
|
+
* 高层 runner:把"批量探索动态菜单 + 抓接口 + 出报告"打包成一个 step。
|
|
1874
|
+
* 当前仅支持 task='menu-api-scan',对应反馈中"129 路由全覆盖"场景。
|
|
1875
|
+
*
|
|
1876
|
+
* options:
|
|
1877
|
+
* task: "menu-api-scan"
|
|
1878
|
+
* menuApiPattern (string|RegExp 默认 /listUserViewMenus/) — 用作 _discoverMenus 的 lastApiCapture 过滤
|
|
1879
|
+
* menuLimit (number 默认 0=不限) — 仅探索前 N 条
|
|
1880
|
+
* menuPathPrefix (Array<string>) — 仅探索 path 开头匹配的菜单
|
|
1881
|
+
* menuFilter (Array<string>) — 仅探索 name/path 包含任一关键字的菜单
|
|
1882
|
+
* drawerTriggerSelector / drawerOpenSelector / menuScrollContainer / useNativeHover / maxRetryPerStep — 透传 _actMenuPath
|
|
1883
|
+
* apiCaptureOptions (object) — 透传 captureApi
|
|
1884
|
+
* continueOnFail (bool 默认 true)
|
|
1885
|
+
* snapshotEach (bool 默认 false) — 每条菜单后做轻量 observe
|
|
1886
|
+
* screenshotEach (bool 默认 false) — 失败时截图(依赖 context.outputDir)
|
|
1887
|
+
*/
|
|
1888
|
+
async runTask(step, context = {}) {
|
|
1889
|
+
const taskName = step.task || step.name || "menu-api-scan";
|
|
1890
|
+
if (taskName !== "menu-api-scan") {
|
|
1891
|
+
return { task: taskName, supported: false, error: `Unsupported task: ${taskName}` };
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
const menus = await this._discoverMenus(step, context);
|
|
1895
|
+
if (!menus.length) {
|
|
1896
|
+
return {
|
|
1897
|
+
task: taskName,
|
|
1898
|
+
discovered: 0,
|
|
1899
|
+
error: "No menus discovered. Run apiCapture targeting menuApiPattern first, or provide step.menus directly.",
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
const limit = Number.parseInt(step.menuLimit ?? 0, 10);
|
|
1904
|
+
const candidates = limit > 0 ? menus.slice(0, limit) : menus;
|
|
1905
|
+
const continueOnFail = step.continueOnFail !== false;
|
|
1906
|
+
const startedAt = Date.now();
|
|
1907
|
+
const results = [];
|
|
1908
|
+
const byOutcome = { clickFailed: 0, noApi: 0, apiOk: 0, apiFailed: 0 };
|
|
1909
|
+
|
|
1910
|
+
for (let i = 0; i < candidates.length; i += 1) {
|
|
1911
|
+
const menu = candidates[i];
|
|
1912
|
+
const itemStartedAt = Date.now();
|
|
1913
|
+
const item = {
|
|
1914
|
+
index: i,
|
|
1915
|
+
menu: { path: menu.path, name: menu.name, names: menu.names },
|
|
1916
|
+
outcome: null,
|
|
1917
|
+
};
|
|
1918
|
+
|
|
1919
|
+
// 1. 点菜单链路(走 act 顶层入口,让它先 _resolveObserveFrame 再分发到 _actMenuPath)
|
|
1920
|
+
const menuStep = {
|
|
1921
|
+
menuPath: menu.names,
|
|
1922
|
+
drawerTriggerSelector: step.drawerTriggerSelector,
|
|
1923
|
+
drawerOpenSelector: step.drawerOpenSelector,
|
|
1924
|
+
menuScrollContainer: step.menuScrollContainer,
|
|
1925
|
+
useNativeHover: step.useNativeHover,
|
|
1926
|
+
maxRetryPerStep: step.maxRetryPerStep,
|
|
1927
|
+
stepDelay: step.stepDelay,
|
|
1928
|
+
frameContentContains: step.frameContentContains,
|
|
1929
|
+
frameUrlContains: step.frameUrlContains,
|
|
1930
|
+
frameIndex: step.frameIndex,
|
|
1931
|
+
};
|
|
1932
|
+
let menuResult;
|
|
1933
|
+
try {
|
|
1934
|
+
menuResult = await this.act(menuStep);
|
|
1935
|
+
} catch (err) {
|
|
1936
|
+
menuResult = { acted: false, error: err.message };
|
|
1937
|
+
}
|
|
1938
|
+
item.menuResult = menuResult;
|
|
1939
|
+
|
|
1940
|
+
if (!menuResult || menuResult.acted === false) {
|
|
1941
|
+
item.outcome = "clickFailed";
|
|
1942
|
+
byOutcome.clickFailed += 1;
|
|
1943
|
+
if (step.screenshotEach) {
|
|
1944
|
+
try {
|
|
1945
|
+
const shot = await this._screenshot({ filename: `runTask-fail-${i}.png`, fullPage: true }, context);
|
|
1946
|
+
item.screenshot = shot;
|
|
1947
|
+
} catch {
|
|
1948
|
+
// ignore
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
results.push({ ...item, durationMs: Date.now() - itemStartedAt });
|
|
1952
|
+
if (!continueOnFail) break;
|
|
1953
|
+
continue;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
// 2. 抓接口
|
|
1957
|
+
const apiStep = {
|
|
1958
|
+
type: "apiCapture",
|
|
1959
|
+
...(step.apiCaptureOptions || {}),
|
|
1960
|
+
wait: step.apiCaptureOptions?.wait ?? 4000,
|
|
1961
|
+
};
|
|
1962
|
+
let apiResult;
|
|
1963
|
+
try {
|
|
1964
|
+
apiResult = await this.captureApi(apiStep);
|
|
1965
|
+
} catch (err) {
|
|
1966
|
+
apiResult = { error: err.message, summary: { total: 0, failed: 0 } };
|
|
1967
|
+
}
|
|
1968
|
+
item.api = apiResult;
|
|
1969
|
+
|
|
1970
|
+
const total = apiResult?.summary?.total ?? 0;
|
|
1971
|
+
const failed = apiResult?.summary?.failed ?? 0;
|
|
1972
|
+
if (total === 0) {
|
|
1973
|
+
item.outcome = "noApi";
|
|
1974
|
+
byOutcome.noApi += 1;
|
|
1975
|
+
} else if (failed > 0) {
|
|
1976
|
+
item.outcome = "apiFailed";
|
|
1977
|
+
byOutcome.apiFailed += 1;
|
|
1978
|
+
} else {
|
|
1979
|
+
item.outcome = "apiOk";
|
|
1980
|
+
byOutcome.apiOk += 1;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
if (step.snapshotEach) {
|
|
1984
|
+
try {
|
|
1985
|
+
item.snapshot = await this.observe({ headingsOnly: true });
|
|
1986
|
+
} catch {
|
|
1987
|
+
// ignore
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
results.push({ ...item, durationMs: Date.now() - itemStartedAt });
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
return {
|
|
1995
|
+
task: taskName,
|
|
1996
|
+
discovered: menus.length,
|
|
1997
|
+
attempted: results.length,
|
|
1998
|
+
durationMs: Date.now() - startedAt,
|
|
1999
|
+
byOutcome,
|
|
2000
|
+
results,
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
/**
|
|
2005
|
+
* 从 lastApiCapture 中找菜单接口响应(默认 /listUserViewMenus/),
|
|
2006
|
+
* 用 CDP getResponseBody 拿到 body 后递归拍平。
|
|
2007
|
+
* 兜底:如果 step.menus 已显式传入,直接用之。
|
|
2008
|
+
*/
|
|
2009
|
+
async _discoverMenus(step = {}, context = {}) {
|
|
2010
|
+
if (Array.isArray(step.menus) && step.menus.length) {
|
|
2011
|
+
return this._flattenMenuTree(step.menus, step.menuPathPrefix, step.menuFilter);
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
const lastCapture = step.captureResult || context.lastApiCapture;
|
|
2015
|
+
if (!lastCapture?.requests?.length) return [];
|
|
2016
|
+
|
|
2017
|
+
const patternRaw = step.menuApiPattern ?? "listUserViewMenus";
|
|
2018
|
+
const pattern = patternRaw instanceof RegExp ? patternRaw : new RegExp(patternRaw);
|
|
2019
|
+
const candidate = lastCapture.requests.find((r) => pattern.test(r.url || ""));
|
|
2020
|
+
if (!candidate) return [];
|
|
2021
|
+
|
|
2022
|
+
let tree = candidate.responseBody;
|
|
2023
|
+
if (typeof tree === "string") {
|
|
2024
|
+
try { tree = JSON.parse(tree); } catch { return []; }
|
|
2025
|
+
}
|
|
2026
|
+
// 常见包装:{ data: [...] } / { result: { data: [...] } }
|
|
2027
|
+
const list =
|
|
2028
|
+
(Array.isArray(tree) && tree) ||
|
|
2029
|
+
tree?.data ||
|
|
2030
|
+
tree?.result?.data ||
|
|
2031
|
+
tree?.result ||
|
|
2032
|
+
[];
|
|
2033
|
+
return this._flattenMenuTree(list, step.menuPathPrefix, step.menuFilter);
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/**
|
|
2037
|
+
* 把菜单树拍平成可点击的链路:
|
|
2038
|
+
* { path, name, names: [一级名, 二级名, ...], depth }
|
|
2039
|
+
* children 字段名兼容 children/subMenus/sub。
|
|
2040
|
+
*/
|
|
2041
|
+
_flattenMenuTree(tree, pathPrefix, keywords) {
|
|
2042
|
+
const out = [];
|
|
2043
|
+
const prefixes = Array.isArray(pathPrefix) ? pathPrefix : pathPrefix ? [pathPrefix] : null;
|
|
2044
|
+
const kws = Array.isArray(keywords) ? keywords : keywords ? [keywords] : null;
|
|
2045
|
+
|
|
2046
|
+
const visit = (nodes, parentNames) => {
|
|
2047
|
+
if (!Array.isArray(nodes)) return;
|
|
2048
|
+
for (const node of nodes) {
|
|
2049
|
+
if (!node) continue;
|
|
2050
|
+
const name = node.name || node.title || node.label || node.menuName;
|
|
2051
|
+
const path = node.path || node.url || node.route;
|
|
2052
|
+
const names = name ? [...parentNames, name] : parentNames.slice();
|
|
2053
|
+
const children = node.children || node.subMenus || node.sub || node.childs;
|
|
2054
|
+
|
|
2055
|
+
// 叶子节点(无 children 或 children 空)才有意义点击触发请求
|
|
2056
|
+
const isLeaf = !Array.isArray(children) || children.length === 0;
|
|
2057
|
+
if (isLeaf && name && path) {
|
|
2058
|
+
let keep = true;
|
|
2059
|
+
if (prefixes && !prefixes.some((p) => path.startsWith(p))) keep = false;
|
|
2060
|
+
if (keep && kws && !kws.some((k) => name.includes(k) || path.includes(k))) keep = false;
|
|
2061
|
+
if (keep) out.push({ path, name, names, depth: names.length });
|
|
2062
|
+
} else if (Array.isArray(children) && children.length) {
|
|
2063
|
+
visit(children, names);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
};
|
|
2067
|
+
visit(tree, []);
|
|
2068
|
+
return out;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
1438
2071
|
_resolveSelector(selector) {
|
|
1439
2072
|
if (!selector) return selector;
|
|
1440
2073
|
// ~.editKnowledge → [class*="editKnowledge"]
|
|
@@ -1469,6 +2102,148 @@ export class BrowserEngine {
|
|
|
1469
2102
|
this.browser = null;
|
|
1470
2103
|
this.page = null;
|
|
1471
2104
|
}
|
|
2105
|
+
|
|
2106
|
+
/**
|
|
2107
|
+
* 执行测试用例:支持 dataset 参数化、变量替换
|
|
2108
|
+
* @param {object} testCase - 用例配置
|
|
2109
|
+
* @param {object} context - 执行上下文
|
|
2110
|
+
* @returns {Promise<object>} 用例执行结果摘要
|
|
2111
|
+
*/
|
|
2112
|
+
async executeTestCase(testCase, context = {}) {
|
|
2113
|
+
const startTime = Date.now();
|
|
2114
|
+
const results = [];
|
|
2115
|
+
const dataset = testCase.dataset || [{ __row: 0 }];
|
|
2116
|
+
|
|
2117
|
+
// setup 阶段:frame 预定位
|
|
2118
|
+
if (testCase.setup?.findFrame && !this._activeFrame) {
|
|
2119
|
+
const resolved = await this._resolveObserveFrame(testCase.setup.findFrame);
|
|
2120
|
+
this._activeFrame = resolved.frame;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
for (let dataRowIndex = 0; dataRowIndex < dataset.length; dataRowIndex++) {
|
|
2124
|
+
const dataRow = dataset[dataRowIndex];
|
|
2125
|
+
const rowStartTime = Date.now();
|
|
2126
|
+
const rowResults = [];
|
|
2127
|
+
let allPassed = true;
|
|
2128
|
+
|
|
2129
|
+
for (let stepIndex = 0; stepIndex < testCase.steps.length; stepIndex++) {
|
|
2130
|
+
const rawStep = testCase.steps[stepIndex];
|
|
2131
|
+
// 变量替换:{{key}} → dataRow[key]
|
|
2132
|
+
const step = this._substituteVariables(rawStep, dataRow);
|
|
2133
|
+
|
|
2134
|
+
// 微前端 frame 探活召回:每次 step 执行前确认 _activeFrame 仍然有效
|
|
2135
|
+
// 如果失效(子应用卸载/路由切换),尝试用 setup.findFrame 重新定位
|
|
2136
|
+
if (this._activeFrame) {
|
|
2137
|
+
const alive = await this._activeFrame.evaluate(() => true).catch(() => false);
|
|
2138
|
+
if (!alive) {
|
|
2139
|
+
this._activeFrame = null;
|
|
2140
|
+
if (testCase.setup?.findFrame) {
|
|
2141
|
+
const recovered = await this._resolveObserveFrame(testCase.setup.findFrame).catch(() => null);
|
|
2142
|
+
if (recovered?.frame) {
|
|
2143
|
+
this._activeFrame = recovered.frame;
|
|
2144
|
+
rowResults.push({
|
|
2145
|
+
type: "frameRecover",
|
|
2146
|
+
status: "PASS",
|
|
2147
|
+
duration: 0,
|
|
2148
|
+
stepIndex,
|
|
2149
|
+
dataRowIndex,
|
|
2150
|
+
matchedBy: recovered.matchedBy,
|
|
2151
|
+
reason: "auto-recovered from frame loss",
|
|
2152
|
+
});
|
|
2153
|
+
results.push(rowResults[rowResults.length - 1]);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
const stepResult = await this.executeStep(step, context);
|
|
2160
|
+
|
|
2161
|
+
rowResults.push({
|
|
2162
|
+
...stepResult,
|
|
2163
|
+
stepIndex,
|
|
2164
|
+
dataRowIndex,
|
|
2165
|
+
dataRow: Object.fromEntries(Object.entries(dataRow).map(([k]) => [k, String(dataRow[k]).slice(0, 80)])),
|
|
2166
|
+
});
|
|
2167
|
+
results.push(rowResults[rowResults.length - 1]);
|
|
2168
|
+
|
|
2169
|
+
if (stepResult.status === "FAIL") {
|
|
2170
|
+
allPassed = false;
|
|
2171
|
+
const recovery = testCase.recovery || {};
|
|
2172
|
+
const maxRetries = recovery.maxRetriesPerStep ?? 0;
|
|
2173
|
+
for (let retry = 0; retry < maxRetries; retry++) {
|
|
2174
|
+
const retryResult = await this.executeStep(step, context);
|
|
2175
|
+
rowResults.push({
|
|
2176
|
+
...retryResult,
|
|
2177
|
+
stepIndex,
|
|
2178
|
+
dataRowIndex,
|
|
2179
|
+
retry: retry + 1,
|
|
2180
|
+
});
|
|
2181
|
+
results.push(rowResults[rowResults.length - 1]);
|
|
2182
|
+
if (retryResult.status === "PASS") {
|
|
2183
|
+
allPassed = true;
|
|
2184
|
+
break;
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
if (!allPassed && step.continueOnFail === false) break;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// frame 切换:如果 step 设置了新 frame,后续步骤默认走新 frame
|
|
2191
|
+
if (step.findFrame || step.frameContentContains || step.frameUrlContains) {
|
|
2192
|
+
const resolved = await this._resolveObserveFrame(step);
|
|
2193
|
+
this._activeFrame = resolved.frame;
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
rowResults.duration = Date.now() - rowStartTime;
|
|
2198
|
+
rowResults.passed = allPassed;
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
const duration = Date.now() - startTime;
|
|
2202
|
+
const passed = results.filter((r) => r.status === "PASS").length;
|
|
2203
|
+
const failed = results.filter((r) => r.status === "FAIL").length;
|
|
2204
|
+
|
|
2205
|
+
return {
|
|
2206
|
+
title: testCase.title,
|
|
2207
|
+
datasetSize: dataset.length,
|
|
2208
|
+
totalSteps: results.length,
|
|
2209
|
+
passedSteps: passed,
|
|
2210
|
+
failedSteps: failed,
|
|
2211
|
+
passed: failed === 0,
|
|
2212
|
+
duration,
|
|
2213
|
+
results,
|
|
2214
|
+
summary: {
|
|
2215
|
+
perDataset: results.reduce((acc, r) => {
|
|
2216
|
+
const key = r.dataRowIndex;
|
|
2217
|
+
if (!acc[key]) acc[key] = { passed: 0, failed: 0, duration: 0 };
|
|
2218
|
+
if (r.status === "PASS") acc[key].passed++;
|
|
2219
|
+
else acc[key].failed++;
|
|
2220
|
+
acc[key].duration += r.duration;
|
|
2221
|
+
return acc;
|
|
2222
|
+
}, {}),
|
|
2223
|
+
},
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
/**
|
|
2228
|
+
* 递归替换对象中的 {{varName}} 变量
|
|
2229
|
+
*/
|
|
2230
|
+
_substituteVariables(obj, dataRow) {
|
|
2231
|
+
if (typeof obj === "string") {
|
|
2232
|
+
return obj.replace(/\{\{([^}]+)\}\}/g, (_, key) => {
|
|
2233
|
+
const keys = key.split(".");
|
|
2234
|
+
let val = dataRow;
|
|
2235
|
+
for (const k of keys) val = val?.[k];
|
|
2236
|
+
return val !== undefined ? (typeof val === "string" ? val : String(val)) : "";
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
if (Array.isArray(obj)) {
|
|
2240
|
+
return obj.map((item) => this._substituteVariables(item, dataRow));
|
|
2241
|
+
}
|
|
2242
|
+
if (obj && typeof obj === "object") {
|
|
2243
|
+
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, this._substituteVariables(v, dataRow)]));
|
|
2244
|
+
}
|
|
2245
|
+
return obj;
|
|
2246
|
+
}
|
|
1472
2247
|
}
|
|
1473
2248
|
|
|
1474
2249
|
/**
|