@szc-ft/mcp-szcd-client 0.28.2 → 0.29.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.
@@ -49,6 +49,28 @@ export class AntAdapter {
49
49
  this.componentMeta = options.componentMeta || {};
50
50
  }
51
51
 
52
+ /**
53
+ * 统一的表单项查找 helper(浏览器端执行)
54
+ * 弹窗/抽屉打开时优先在其中搜索,避免页面搜索栏的同名 label 被错误命中
55
+ * 用法:在 frame.evaluate 内调用 _findFormItem(label)
56
+ */
57
+ static get FORM_ITEM_HELPER() {
58
+ return `
59
+ function _findFormItem(label) {
60
+ var modalOrDrawer = document.querySelector('.ant-modal:not(.ant-modal-hidden), .ant-drawer:not(.ant-drawer-hidden), .ant-drawer-open .ant-drawer-content-wrapper');
61
+ var scope = modalOrDrawer || document;
62
+ var items = Array.from(scope.querySelectorAll('.ant-form-item'));
63
+ if (items.length === 0 && scope !== document) {
64
+ items = items.concat(Array.from(document.querySelectorAll('.ant-form-item')));
65
+ }
66
+ return items.find(function(el) {
67
+ var lbl = el.querySelector('.ant-form-item-label label, .ant-form-item-label');
68
+ return lbl && (lbl.innerText || lbl.textContent || '').trim().includes(label);
69
+ }) || null;
70
+ }
71
+ `;
72
+ }
73
+
52
74
  /**
53
75
  * 关闭所有打开的下拉浮层 / 抽屉 / 弹窗
54
76
  * 反馈第 2 条:每次 Select 操作后必须调用,否则下次操作会命中残留选项
@@ -61,9 +83,9 @@ export class AntAdapter {
61
83
  });
62
84
  // 2) 隐藏所有未隐藏的下拉浮层
63
85
  const dropdowns = document.querySelectorAll(
64
- "body > .ant-select-dropdown:not(.ant-select-dropdown-hidden), " +
65
- "body > .ant-cascader-dropdown:not(.ant-cascader-dropdown-hidden), " +
66
- "body > .ant-picker-dropdown:not(.ant-picker-dropdown-hidden)"
86
+ ".ant-select-dropdown:not(.ant-select-dropdown-hidden), " +
87
+ ".ant-cascader-dropdown:not(.ant-cascader-dropdown-hidden), " +
88
+ ".ant-picker-dropdown:not(.ant-picker-dropdown-hidden)"
67
89
  );
68
90
  const count = dropdowns.length;
69
91
  dropdowns.forEach((el) => {
@@ -79,39 +101,45 @@ export class AntAdapter {
79
101
  }).catch(() => ({ closed: 0 }));
80
102
  }
81
103
 
104
+ /**
105
+ * 在 frame 上执行 evaluate,自动注入 _findFormItem helper
106
+ * 后续所有新方法只需调用本方法,无需手动处理弹窗/抽屉优先逻辑
107
+ */
108
+ async _evalWithFormHelper(frame, fnBody) {
109
+ const wrapped = "(function() {" + AntAdapter.FORM_ITEM_HELPER + "return (" + fnBody + ")();\n})()";
110
+ return frame.evaluate(wrapped);
111
+ }
112
+
82
113
  /**
83
114
  * 定位包含指定 label 的 .ant-form-item,返回其内 control 元素的 selector 信息
84
115
  * 不直接返回 ElementHandle(跨 evaluate 边界丢失),而是返回一个临时标记 id
85
116
  */
86
117
  async _markFormItemControl(frame, labelText, controlHint) {
87
- return frame.evaluate(({ label, hint }) => {
88
- const items = Array.from(document.querySelectorAll(".ant-form-item"));
89
- const item = items.find((el) => {
90
- const lbl = el.querySelector(".ant-form-item-label label, .ant-form-item-label");
91
- if (!lbl) return false;
92
- return (lbl.innerText || lbl.textContent || "").trim().includes(label);
93
- });
118
+ const label = labelText.replace(/'/g, "\\'");
119
+ const hint = controlHint;
120
+ return this._evalWithFormHelper(frame, `() => {
121
+ const item = _findFormItem('${label}');
94
122
  if (!item) return { found: false };
95
123
 
96
124
  // 找控件:根据 hint 决定优先匹配何种节点
97
125
  let control;
98
- if (hint === "select") {
126
+ if (${JSON.stringify(hint)} === "select") {
99
127
  control = item.querySelector(".ant-select:not(.ant-select-disabled)");
100
- } else if (hint === "treeSelect") {
128
+ } else if (${JSON.stringify(hint)} === "treeSelect") {
101
129
  control = item.querySelector(".ant-select.ant-tree-select, .ant-tree-select");
102
- } else if (hint === "cascader") {
130
+ } else if (${JSON.stringify(hint)} === "cascader") {
103
131
  control = item.querySelector(".ant-cascader, .ant-cascader-picker");
104
- } else if (hint === "datePicker") {
132
+ } else if (${JSON.stringify(hint)} === "datePicker") {
105
133
  control = item.querySelector(".ant-picker");
106
- } else if (hint === "input") {
134
+ } else if (${JSON.stringify(hint)} === "input") {
107
135
  control = item.querySelector("input.ant-input, textarea.ant-input, .ant-input-affix-wrapper input");
108
- } else if (hint === "radio") {
136
+ } else if (${JSON.stringify(hint)} === "radio") {
109
137
  control = item.querySelector(".ant-radio-group");
110
- } else if (hint === "checkbox") {
138
+ } else if (${JSON.stringify(hint)} === "checkbox") {
111
139
  control = item.querySelector(".ant-checkbox-group, .ant-checkbox-wrapper");
112
- } else if (hint === "switch") {
140
+ } else if (${JSON.stringify(hint)} === "switch") {
113
141
  control = item.querySelector(".ant-switch");
114
- } else if (hint === "upload") {
142
+ } else if (${JSON.stringify(hint)} === "upload") {
115
143
  control = item.querySelector(".ant-upload input[type='file'], input[type='file']");
116
144
  } else {
117
145
  control = item.querySelector("input, .ant-select, .ant-picker, .ant-cascader");
@@ -120,7 +148,7 @@ export class AntAdapter {
120
148
  if (!control) return { found: true, hasControl: false };
121
149
 
122
150
  // 用临时 data 属性标记,避免重复 traversal
123
- const marker = `__sz_marker_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
151
+ const marker = '__sz_marker_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
124
152
  control.setAttribute("data-sz-marker", marker);
125
153
  item.setAttribute("data-sz-form-item-marker", marker);
126
154
  const rect = control.getBoundingClientRect();
@@ -131,7 +159,7 @@ export class AntAdapter {
131
159
  tagName: control.tagName,
132
160
  visible: rect.width > 0 && rect.height > 0,
133
161
  };
134
- }, { label: labelText, hint: controlHint });
162
+ }`);
135
163
  }
136
164
 
137
165
  async _clearMarkers(frame) {
@@ -189,12 +217,12 @@ export class AntAdapter {
189
217
  }
190
218
 
191
219
  const selectedSummary = [];
220
+ let popupResolved = popupFrame;
192
221
  for (const opt of options) {
193
222
  // 3) 浮层探测:先在触发器 frame 找,没找到再在 popupFrame 找(兜底)
194
- let popupResolved = popupFrame;
195
223
  let optionFound = await pollEval(popupResolved, (text) => {
196
224
  const items = Array.from(document.querySelectorAll(
197
- "body > .ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option"
225
+ ".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option"
198
226
  ));
199
227
  const visibleItems = items.filter((el) => {
200
228
  const rect = el.getBoundingClientRect();
@@ -220,7 +248,7 @@ export class AntAdapter {
220
248
  if (parentFrame && parentFrame !== frame) {
221
249
  const retryFound = await pollEval(parentFrame, (text) => {
222
250
  const items = Array.from(document.querySelectorAll(
223
- "body > .ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option"
251
+ ".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option"
224
252
  ));
225
253
  const visibleItems = items.filter((el) => {
226
254
  const rect = el.getBoundingClientRect();
@@ -257,12 +285,13 @@ export class AntAdapter {
257
285
  };
258
286
  }
259
287
 
260
- // 4) mousedown 选项(在 popupResolved frame 内)
288
+ // 4) 点击选项(在 popupResolved frame 内),需同时触发 mousedown+click 才可驱动 React onChange
261
289
  await popupResolved.evaluate((tag) => {
262
290
  const el = document.querySelector(`[data-sz-option-marker="${tag}"]`);
263
291
  if (!el) return false;
264
292
  el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
265
293
  el.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
294
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
266
295
  el.removeAttribute("data-sz-option-marker");
267
296
  return true;
268
297
  }, optionFound.value.tag);
@@ -338,8 +367,8 @@ export class AntAdapter {
338
367
 
339
368
  const found = await pollEval(frame, ({ text, isLeaf }) => {
340
369
  const titles = Array.from(document.querySelectorAll(
341
- "body > .ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-tree-title, " +
342
- "body > .ant-tree-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-tree-title"
370
+ ".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-tree-title, " +
371
+ ".ant-tree-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-tree-title"
343
372
  ));
344
373
  const visible = titles.filter((el) => {
345
374
  const rect = el.getBoundingClientRect();
@@ -408,29 +437,33 @@ export class AntAdapter {
408
437
  */
409
438
  async input(frame, target, value, opts = {}) {
410
439
  const clear = opts.clear !== false;
411
- const result = await frame.evaluate(({ target, value, clear }) => {
412
- let el = null;
413
-
414
- if (target.label) {
415
- const items = Array.from(document.querySelectorAll(".ant-form-item"));
416
- const item = items.find((x) => {
417
- const lbl = x.querySelector(".ant-form-item-label");
418
- return lbl && (lbl.innerText || lbl.textContent || "").trim().includes(target.label);
419
- });
420
- el = item?.querySelector("input.ant-input, textarea.ant-input, .ant-input-affix-wrapper input, input[type='text'], input[type='number'], input[type='password'], input:not([type])");
421
- } else if (target.placeholder) {
422
- el = document.querySelector(`input[placeholder*="${target.placeholder}"], textarea[placeholder*="${target.placeholder}"]`);
423
- } else if (target.selector) {
424
- try { el = document.querySelector(target.selector); } catch { el = null; }
440
+ const label = (target.label || '').replace(/'/g, "\\'");
441
+ const placeholder = (target.placeholder || '').replace(/'/g, "\\'");
442
+ const selector = (target.selector || '').replace(/'/g, "\\'");
443
+
444
+ const result = await this._evalWithFormHelper(frame, `() => {
445
+ var el = null;
446
+ var val = ${JSON.stringify(value)};
447
+ var shouldClear = ${clear};
448
+
449
+ var item = _findFormItem('${label}');
450
+ if (item) {
451
+ el = item.querySelector("input.ant-input, textarea.ant-input, .ant-input-affix-wrapper input, input[type='text'], input[type='number'], input[type='password'], input:not([type])");
452
+ }
453
+ if (!el && '${placeholder}') {
454
+ el = document.querySelector('input[placeholder*="${placeholder}"], textarea[placeholder*="${placeholder}"]');
455
+ }
456
+ if (!el && '${selector}') {
457
+ try { el = document.querySelector('${selector}'); } catch(e) { el = null; }
425
458
  }
426
459
 
427
460
  if (!el) return { found: false };
428
461
  el.scrollIntoView({ block: "center" });
429
- const proto = Object.getPrototypeOf(el);
430
- const valueSetter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
462
+ var proto = Object.getPrototypeOf(el);
463
+ var valueSetter = Object.getOwnPropertyDescriptor(proto, "value") && Object.getOwnPropertyDescriptor(proto, "value").set;
431
464
 
432
465
  el.focus();
433
- const newValue = clear ? value : (el.value || "") + value;
466
+ var newValue = shouldClear ? val : (el.value || "") + val;
434
467
  if (valueSetter) {
435
468
  valueSetter.call(el, newValue);
436
469
  } else {
@@ -444,7 +477,7 @@ export class AntAdapter {
444
477
  placeholder: el.getAttribute("placeholder"),
445
478
  tagName: el.tagName,
446
479
  };
447
- }, { target, value, clear });
480
+ }`);
448
481
 
449
482
  return {
450
483
  acted: result.found,
@@ -460,27 +493,26 @@ export class AntAdapter {
460
493
  * Ant Radio 选择
461
494
  */
462
495
  async radio(frame, fieldLabel, optionLabel) {
463
- const result = await frame.evaluate(({ field, opt }) => {
464
- const items = Array.from(document.querySelectorAll(".ant-form-item"));
465
- const item = items.find((x) => {
466
- const lbl = x.querySelector(".ant-form-item-label");
467
- return lbl && (lbl.innerText || lbl.textContent || "").trim().includes(field);
468
- });
496
+ const field = fieldLabel.replace(/'/g, "\\'");
497
+ const opt = optionLabel.replace(/'/g, "\\'");
498
+
499
+ const result = await this._evalWithFormHelper(frame, `() => {
500
+ var item = _findFormItem('${field}');
469
501
  if (!item) return { found: false, reason: "form item not found" };
470
502
 
471
- const wrappers = Array.from(item.querySelectorAll(".ant-radio-wrapper"));
472
- const target = wrappers.find((w) => (w.innerText || "").trim().includes(opt));
473
- if (!target) return { found: false, reason: `radio option "${opt}" not found`, available: wrappers.map((w) => (w.innerText || "").trim()) };
503
+ var wrappers = Array.from(item.querySelectorAll(".ant-radio-wrapper"));
504
+ var target = wrappers.find(function(w) { return (w.innerText || "").trim().includes('${opt}'); });
505
+ if (!target) return { found: false, reason: 'radio option not found', available: wrappers.map(function(w) { return (w.innerText || "").trim(); }) };
474
506
 
475
507
  target.scrollIntoView({ block: "center" });
476
508
  target.click();
477
- const input = target.querySelector("input[type='radio']");
509
+ var input = target.querySelector("input[type='radio']");
478
510
  return {
479
511
  found: true,
480
- checked: input?.checked || false,
481
- value: input?.value || "",
512
+ checked: input && input.checked || false,
513
+ value: input && input.value || "",
482
514
  };
483
- }, { field: fieldLabel, opt: optionLabel });
515
+ }`);
484
516
  return {
485
517
  acted: result.found,
486
518
  field: fieldLabel,
@@ -495,24 +527,20 @@ export class AntAdapter {
495
527
  * Ant Checkbox 切换(按 label 文本)
496
528
  */
497
529
  async checkbox(frame, fieldLabel, optionLabel, checked = true) {
498
- const result = await frame.evaluate(({ field, opt, target }) => {
499
- const items = Array.from(document.querySelectorAll(".ant-form-item"));
500
- const item = items.find((x) => {
501
- const lbl = x.querySelector(".ant-form-item-label");
502
- return lbl && (lbl.innerText || lbl.textContent || "").trim().includes(field);
503
- });
530
+ const field = fieldLabel.replace(/'/g, "\\'");
531
+ const opt = (optionLabel || '').replace(/'/g, "\\'");
532
+
533
+ const result = await this._evalWithFormHelper(frame, `() => {
534
+ var item = _findFormItem('${field}');
504
535
  if (!item) return { found: false };
505
- // 支持 group 和单独 checkbox
506
- const wrappers = Array.from(item.querySelectorAll(".ant-checkbox-wrapper"));
507
- const wrapper = opt
508
- ? wrappers.find((w) => (w.innerText || "").includes(opt))
509
- : wrappers[0];
536
+ var wrappers = Array.from(item.querySelectorAll(".ant-checkbox-wrapper"));
537
+ var wrapper = '${opt}' ? wrappers.find(function(w) { return (w.innerText || "").includes('${opt}'); }) : wrappers[0];
510
538
  if (!wrapper) return { found: false, reason: "checkbox not found" };
511
- const input = wrapper.querySelector("input[type='checkbox']");
539
+ var input = wrapper.querySelector("input[type='checkbox']");
512
540
  if (!input) return { found: false };
513
- if (input.checked !== target) wrapper.click();
541
+ if (input.checked !== ${checked}) wrapper.click();
514
542
  return { found: true, checked: input.checked };
515
- }, { field: fieldLabel, opt: optionLabel, target: checked });
543
+ }`);
516
544
  return { acted: result.found, field: fieldLabel, option: optionLabel, verified: result.checked === checked };
517
545
  }
518
546
 
@@ -520,19 +548,17 @@ export class AntAdapter {
520
548
  * Ant Switch 切换
521
549
  */
522
550
  async switch(frame, fieldLabel, on = true) {
523
- const result = await frame.evaluate(({ field, target }) => {
524
- const items = Array.from(document.querySelectorAll(".ant-form-item"));
525
- const item = items.find((x) => {
526
- const lbl = x.querySelector(".ant-form-item-label");
527
- return lbl && (lbl.innerText || lbl.textContent || "").trim().includes(field);
528
- });
551
+ const field = fieldLabel.replace(/'/g, "\\'");
552
+
553
+ const result = await this._evalWithFormHelper(frame, `() => {
554
+ var item = _findFormItem('${field}');
529
555
  if (!item) return { found: false };
530
- const sw = item.querySelector(".ant-switch");
556
+ var sw = item.querySelector(".ant-switch");
531
557
  if (!sw) return { found: false };
532
- const isOn = sw.getAttribute("aria-checked") === "true" || sw.classList.contains("ant-switch-checked");
533
- if (isOn !== target) sw.click();
534
- return { found: true, state: target };
535
- }, { field: fieldLabel, target: on });
558
+ var isOn = sw.getAttribute("aria-checked") === "true" || sw.classList.contains("ant-switch-checked");
559
+ if (isOn !== ${on}) sw.click();
560
+ return { found: true, state: ${on} };
561
+ }`);
536
562
  return { acted: result.found, field: fieldLabel, state: result.state, verified: result.state === on };
537
563
  }
538
564
 
@@ -541,29 +567,27 @@ export class AntAdapter {
541
567
  */
542
568
  async datePicker(frame, fieldLabel, dateString) {
543
569
  await this.closeAllDropdowns(frame);
544
- const result = await frame.evaluate(({ field, value }) => {
545
- const items = Array.from(document.querySelectorAll(".ant-form-item"));
546
- const item = items.find((x) => {
547
- const lbl = x.querySelector(".ant-form-item-label");
548
- return lbl && (lbl.innerText || lbl.textContent || "").trim().includes(field);
549
- });
570
+ const field = fieldLabel.replace(/'/g, "\\'");
571
+ const val = (dateString || '').replace(/'/g, "\\'");
572
+
573
+ const result = await this._evalWithFormHelper(frame, `() => {
574
+ var item = _findFormItem('${field}');
550
575
  if (!item) return { found: false };
551
- const picker = item.querySelector(".ant-picker");
552
- const input = picker?.querySelector("input");
576
+ var picker = item.querySelector(".ant-picker");
577
+ var input = picker && picker.querySelector("input");
553
578
  if (!input) return { found: false };
554
579
 
555
580
  input.scrollIntoView({ block: "center" });
556
581
  input.focus();
557
- const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), "value")?.set;
558
- if (setter) setter.call(input, value);
559
- else input.value = value;
582
+ var setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), "value") && Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), "value").set;
583
+ if (setter) setter.call(input, '${val}');
584
+ else input.value = '${val}';
560
585
  input.dispatchEvent(new Event("input", { bubbles: true }));
561
586
  input.dispatchEvent(new Event("change", { bubbles: true }));
562
- // 回车确认
563
587
  input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
564
588
  input.blur();
565
589
  return { found: true, value: input.value };
566
- }, { field: fieldLabel, value: dateString });
590
+ }`);
567
591
  await this.closeAllDropdowns(frame);
568
592
  return { acted: result.found, field: fieldLabel, value: result.value, verified: result.value === dateString };
569
593
  }
@@ -596,7 +620,7 @@ export class AntAdapter {
596
620
  const seg = segments[i];
597
621
  const result = await pollEval(frame, (text) => {
598
622
  const all = Array.from(document.querySelectorAll(
599
- "body > .ant-cascader-dropdown:not(.ant-cascader-dropdown-hidden) .ant-cascader-menu-item"
623
+ ".ant-cascader-dropdown:not(.ant-cascader-dropdown-hidden) .ant-cascader-menu-item"
600
624
  ));
601
625
  const visible = all.filter((el) => {
602
626
  const r = el.getBoundingClientRect();
@@ -625,21 +649,16 @@ export class AntAdapter {
625
649
  * Upload 文件上传
626
650
  */
627
651
  async upload(frame, fieldLabel, filePath) {
628
- // 注意:puppeteer 上传文件需要通过 ElementHandle.uploadFile,evaluate 内做不到
629
- // 这里只能返回需要上层处理的标志
630
- const result = await frame.evaluate((field) => {
631
- const items = Array.from(document.querySelectorAll(".ant-form-item"));
632
- const item = items.find((x) => {
633
- const lbl = x.querySelector(".ant-form-item-label");
634
- return lbl && (lbl.innerText || lbl.textContent || "").trim().includes(field);
635
- });
652
+ const field = fieldLabel.replace(/'/g, "\\'");
653
+ const result = await this._evalWithFormHelper(frame, `() => {
654
+ var item = _findFormItem('${field}');
636
655
  if (!item) return { found: false };
637
- const input = item.querySelector("input[type='file']");
656
+ var input = item.querySelector("input[type='file']");
638
657
  if (!input) return { found: false };
639
- const marker = `__sz_upload_${Date.now()}`;
658
+ var marker = '__sz_upload_' + Date.now();
640
659
  input.setAttribute("data-sz-marker", marker);
641
- return { found: true, marker };
642
- }, fieldLabel);
660
+ return { found: true, marker: marker };
661
+ }`);
643
662
 
644
663
  if (!result.found) return { acted: false, reason: "upload input not found" };
645
664
 
@@ -24,6 +24,7 @@ export class BrowserEngine {
24
24
  this._activeFrame = null; // 当前活跃 iframe 上下文(微前端场景)
25
25
  this._adapter = null; // 懒加载 AntAdapter
26
26
  this._lastActWasDropdown = false; // 用于 act 入口自动 closeAllDropdowns
27
+ this._wujieContext = null; // wujie 微前端模式 { type, frameCount, wujieApp? }
27
28
  }
28
29
 
29
30
  /**
@@ -72,6 +73,133 @@ export class BrowserEngine {
72
73
  }
73
74
  }
74
75
 
76
+ /**
77
+ * 检测 wujie 微前端模式(WebComponent / iframe)
78
+ * @returns {{ type: 'webcomponent'|'iframe'|'none', frameCount: number, wujieApp?: object }}
79
+ */
80
+ async _detectWujieMode() {
81
+ const frames = this.page.frames();
82
+ const frameCount = frames.length;
83
+ const mainFrame = this.page.mainFrame();
84
+
85
+ // 检查主文档中是否有 <wujie-app> 自定义元素(WebComponent 模式标志)
86
+ const wujieInfo = await mainFrame.evaluate(() => {
87
+ const app = document.querySelector('wujie-app');
88
+ if (!app) return null;
89
+ const sr = app.shadowRoot;
90
+ return {
91
+ tagName: app.tagName,
92
+ className: app.className,
93
+ hasShadowRoot: !!sr,
94
+ shadowChildren: sr?.children?.length || 0,
95
+ shadowBodyText: sr?.querySelector('body')?.innerText?.substring(0, 80) || ''
96
+ };
97
+ }).catch(() => null);
98
+
99
+ if (wujieInfo) {
100
+ const ctx = { type: 'webcomponent', frameCount, wujieApp: wujieInfo };
101
+ process.stderr.write(`[wujie] WebComponent 模式: <${wujieInfo.tagName} class="${wujieInfo.className}"> shadowRoot=${wujieInfo.hasShadowRoot} children=${wujieInfo.shadowChildren} frames=${frameCount}\n`);
102
+ if (wujieInfo.shadowBodyText) {
103
+ process.stderr.write(`[wujie] shadow body preview: "${wujieInfo.shadowBodyText}"\n`);
104
+ }
105
+ // 列出所有 frame 供排障
106
+ for (let i = 0; i < frames.length; i++) {
107
+ process.stderr.write(`[wujie] frame[${i}]: ${frames[i].url().substring(0, 100)}\n`);
108
+ }
109
+ return ctx;
110
+ }
111
+
112
+ if (frameCount > 1) {
113
+ process.stderr.write(`[wujie] iframe 模式: ${frameCount} frames\n`);
114
+ for (let i = 0; i < frames.length; i++) {
115
+ process.stderr.write(`[wujie] frame[${i}]: ${frames[i].url().substring(0, 100)}\n`);
116
+ }
117
+ return { type: 'iframe', frameCount };
118
+ }
119
+
120
+ process.stderr.write(`[wujie] 未检测到微前端 (frames=${frameCount})\n`);
121
+ return { type: 'none', frameCount };
122
+ }
123
+
124
+ /**
125
+ * 安全获取页面列表(优先同步 targets() 避免 pages() 挂死 + 泄露 WebSocket)
126
+ */
127
+ async _safeGetPages() {
128
+ // 1. 优先使用同步 targets(),不会挂死也不会泄露未完成的 promise
129
+ try {
130
+ const targets = this.browser.targets();
131
+ const pageTargets = targets.filter((t) => t.type() === "page");
132
+ if (pageTargets.length > 0) {
133
+ const pages = [];
134
+ for (const t of pageTargets) {
135
+ try {
136
+ const p = await Promise.race([
137
+ t.page(),
138
+ new Promise((_, reject) => setTimeout(() => reject(new Error("page() timeout")), 3000)),
139
+ ]);
140
+ if (p) pages.push(p);
141
+ } catch {
142
+ // 跳过无法获取的 target
143
+ }
144
+ }
145
+ if (pages.length > 0) return pages;
146
+ }
147
+ } catch {
148
+ // targets() 失败
149
+ }
150
+
151
+ // 2. 回退:CDP Target.getTargets
152
+ try {
153
+ const client = await this.browser.target().createCDPSession();
154
+ const { targetInfos } = await client.send("Target.getTargets");
155
+ const pageInfos = targetInfos.filter((t) => t.type === "page");
156
+ const pages = [];
157
+ for (const info of pageInfos) {
158
+ try {
159
+ const target = await Promise.race([
160
+ this.browser.waitForTarget((t) => t.url() === info.url, { timeout: 2000 }),
161
+ new Promise((_, reject) => setTimeout(() => reject(new Error("waitForTarget timeout")), 3000)),
162
+ ]);
163
+ if (target) {
164
+ const p = await Promise.race([
165
+ target.page(),
166
+ new Promise((_, reject) => setTimeout(() => reject(new Error("page() timeout")), 3000)),
167
+ ]);
168
+ if (p) pages.push(p);
169
+ }
170
+ } catch {
171
+ // skip
172
+ }
173
+ }
174
+ if (pages.length > 0) return pages;
175
+ } catch {
176
+ // CDP 回退也失败
177
+ }
178
+
179
+ // 3. 最后手段:只能尝试 pages()(带严格超时,用完立即 disconnect/reconnect 清理)
180
+ try {
181
+ const pages = await Promise.race([
182
+ this.browser.pages(),
183
+ new Promise((_, reject) => setTimeout(() => reject(new Error("pages() timeout")), 5000)),
184
+ ]);
185
+ if (pages && pages.length > 0) return pages;
186
+ } catch {
187
+ // pages() 超时;注意此时 browser.pages() 的 WebSocket 可能泄露
188
+ // 清理:断开后重新连接
189
+ try {
190
+ const wsUrl = this.browser.wsEndpoint();
191
+ const puppeteer = await loadPuppeteer();
192
+ await this.browser.disconnect();
193
+ this.browser = await puppeteer.connect({ browserWSEndpoint: wsUrl, defaultViewport: null });
194
+ } catch {
195
+ // reconnect 失败,继续用当前 browser
196
+ }
197
+ }
198
+
199
+ // 4. 最后手段:newPage()
200
+ return [await this.browser.newPage()];
201
+ }
202
+
75
203
  /**
76
204
  * 模式 A:连接到用户已有的 Chrome
77
205
  */
@@ -82,7 +210,7 @@ export class BrowserEngine {
82
210
  this.ownedBrowser = false;
83
211
 
84
212
  // 获取页面:优先使用指定 URL 匹配的标签页
85
- const pages = await this.browser.pages();
213
+ const pages = await this._safeGetPages();
86
214
  if (options.pageUrl) {
87
215
  this.page = pages.find((p) => p.url().includes(options.pageUrl))
88
216
  || await this.browser.newPage();
@@ -96,6 +224,9 @@ export class BrowserEngine {
96
224
  await this.page.setViewport(options.viewport);
97
225
  }
98
226
 
227
+ // 微前端模式检测
228
+ this._wujieContext = await this._detectWujieMode();
229
+
99
230
  return {
100
231
  mode: "connect",
101
232
  cdpUrl,
@@ -223,6 +354,7 @@ export class BrowserEngine {
223
354
  frameUrl: targetFrame.frame.url(),
224
355
  matchedBy: targetFrame.matchedBy,
225
356
  },
357
+ wujieContext: this._wujieContext,
226
358
  frames,
227
359
  tree,
228
360
  interactiveElements,
@@ -247,7 +379,10 @@ export class BrowserEngine {
247
379
 
248
380
  const targetFrame = await this._resolveObserveFrame(options);
249
381
  if (options.menuPath) {
250
- return this._actMenuPath(targetFrame, options);
382
+ // 菜单在 main frame 外壳中渲染,不随子应用切换
383
+ const menuFrame = this.page.mainFrame();
384
+ const menuTarget = { frame: menuFrame, frameId: "main", matchedBy: "main (menu shell)" };
385
+ return this._actMenuPath(menuTarget, options);
251
386
  }
252
387
 
253
388
  const index = options.index !== undefined ? Number.parseInt(options.index, 10) : undefined;
@@ -944,6 +1079,15 @@ export class BrowserEngine {
944
1079
  }
945
1080
 
946
1081
  async _resolveObserveFrame(options = {}) {
1082
+ // testCase setup 已预定位 iframe,且调用方未显式传 frame 参数 → 直接复用
1083
+ if (this._activeFrame && options.frameIndex === undefined && !options.frameUrlContains && !options.frameContentContains) {
1084
+ const frames = this.page.frames();
1085
+ const idx = frames.indexOf(this._activeFrame);
1086
+ if (idx >= 0) {
1087
+ return { frame: this._activeFrame, frameId: idx === 0 ? "main" : `frame-${idx}`, matchedBy: "activeFrame (from testCase setup)" };
1088
+ }
1089
+ }
1090
+
947
1091
  const frames = this.page.frames();
948
1092
  const mainFrame = this.page.mainFrame();
949
1093
 
@@ -973,6 +1117,14 @@ export class BrowserEngine {
973
1117
  }
974
1118
  }
975
1119
 
1120
+ // 无显式 frame 参数时:WebComponent 模式默认用 sandbox iframe(含子应用实际 DOM)
1121
+ if (this._wujieContext?.type === 'webcomponent' && frames.length > 1) {
1122
+ const sandboxIdx = frames.findIndex((f) => f !== mainFrame && !f.url().startsWith('about:'));
1123
+ if (sandboxIdx >= 0) {
1124
+ return { frame: frames[sandboxIdx], frameId: `frame-${sandboxIdx}`, matchedBy: 'webcomponent sandbox iframe' };
1125
+ }
1126
+ }
1127
+
976
1128
  const mainIndex = frames.indexOf(mainFrame);
977
1129
  return { frame: mainFrame, frameId: mainIndex <= 0 ? "main" : `frame-${mainIndex}`, matchedBy: "main" };
978
1130
  }
@@ -1693,7 +1845,7 @@ export class BrowserEngine {
1693
1845
  * 查找已打开的标签页(connect 模式,端口可变场景)
1694
1846
  */
1695
1847
  async _findPage(step) {
1696
- const pages = await this.browser.pages();
1848
+ const pages = await this._safeGetPages();
1697
1849
  let matched = null;
1698
1850
  let matchReason = "";
1699
1851
 
@@ -0,0 +1 @@
1
+ {"type": "module"}