@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.
@@ -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
- 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));
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 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
- }
443
+ const frame = targetFrame.frame;
401
444
 
402
- function classText(el) {
403
- return typeof el.className === "string" ? el.className : "";
404
- }
405
-
406
- function isVisible(el) {
407
- const rect = el.getBoundingClientRect();
408
- const style = window.getComputedStyle(el);
409
- return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
410
- }
411
-
412
- function menuCandidates() {
413
- return Array.from(document.querySelectorAll([
414
- "[role='menuitem']",
415
- ".ant-menu-item",
416
- ".ant-menu-submenu-title",
417
- "li[class*='menu']",
418
- "a",
419
- "button",
420
- "[class*='menu-item']",
421
- "[class*='submenu']",
422
- ].join(","))).filter(isVisible);
423
- }
424
-
425
- function scoreCandidate(el, text, level) {
426
- const actual = textOf(el);
427
- if (!actual) return -1;
428
- let score = -1;
429
- if (actual === text) score = 100;
430
- else if (actual.includes(text)) score = 70;
431
- else if (text.includes(actual)) score = 50;
432
- if (score < 0) return -1;
433
- const cls = classText(el);
434
- const role = el.getAttribute("role") || "";
435
- if (role === "menuitem") score += 10;
436
- if (cls.includes("ant-menu-submenu-title")) score += level < pathItems.length - 1 ? 20 : -5;
437
- if (cls.includes("ant-menu-item")) score += level === pathItems.length - 1 ? 20 : 0;
438
- if (cls.includes("ant-menu-item-selected")) score += 5;
439
- return score;
440
- }
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
- 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
- }
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
- function elementSnapshot(el) {
450
- const rect = el.getBoundingClientRect();
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
- 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"),
530
+ found: true,
531
+ matchedText: top.text,
457
532
  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),
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
- const el = match.el;
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
- 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,
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: afterClass,
501
- ariaExpanded: afterExpanded,
562
+ className: cls,
563
+ ariaExpanded: el.getAttribute("aria-expanded"),
502
564
  urlChanged: location.href !== beforeUrl,
503
565
  textChanged: (document.body?.innerText || "") !== beforeText,
504
- selected: afterClass.includes("selected") || afterClass.includes("active"),
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
- return {
510
- found: true,
511
- performed: true,
512
- steps,
513
- currentUrl: location.href,
514
- title: document.title,
515
- };
516
- }, menuPath, waitAfterEach);
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
- if (!result.found) {
519
- throw new Error(`Menu path item not found: ${result.failedAt}`);
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: result.steps,
532
- currentUrl: result.currentUrl,
533
- title: result.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 attachCandidates = this.browser.targets().filter((target) => {
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
- 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
- });
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
  /**