@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.
- package/agents/szcd-component-expert.qoder.md +1 -0
- package/opencode-extension/skills/local-browser-test/SKILL.md +122 -19
- package/opencode-extension/skills/local-browser-test/lib/ant-adapter.js +128 -109
- package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +155 -3
- package/opencode-extension/skills/local-browser-test/package.json +1 -0
- package/opencode-extension/skills/szcd-upload-zip/SKILL.md +122 -0
- package/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +122 -19
- package/qwen-extension/skills/local-browser-test/lib/ant-adapter.js +128 -109
- package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +155 -3
- package/qwen-extension/skills/local-browser-test/package.json +1 -0
- package/qwen-extension/skills/szcd-upload-zip/SKILL.md +122 -0
- package/standard-skill/local-browser-test/SKILL.md +122 -19
- package/standard-skill/local-browser-test/lib/ant-adapter.js +128 -109
- package/standard-skill/local-browser-test/lib/browser-engine.js +155 -3
- package/standard-skill/local-browser-test/package.json +1 -0
- package/standard-skill/szcd-upload-zip/SKILL.md +122 -0
|
@@ -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
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 =
|
|
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
|
-
}
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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)
|
|
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
|
-
"
|
|
342
|
-
"
|
|
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
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
el =
|
|
423
|
-
}
|
|
424
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
if (!target) return { found: false, reason:
|
|
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
|
-
|
|
509
|
+
var input = target.querySelector("input[type='radio']");
|
|
478
510
|
return {
|
|
479
511
|
found: true,
|
|
480
|
-
checked: input
|
|
481
|
-
value: input
|
|
512
|
+
checked: input && input.checked || false,
|
|
513
|
+
value: input && input.value || "",
|
|
482
514
|
};
|
|
483
|
-
}
|
|
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
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
506
|
-
|
|
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
|
-
|
|
539
|
+
var input = wrapper.querySelector("input[type='checkbox']");
|
|
512
540
|
if (!input) return { found: false };
|
|
513
|
-
if (input.checked !==
|
|
541
|
+
if (input.checked !== ${checked}) wrapper.click();
|
|
514
542
|
return { found: true, checked: input.checked };
|
|
515
|
-
}
|
|
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
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
556
|
+
var sw = item.querySelector(".ant-switch");
|
|
531
557
|
if (!sw) return { found: false };
|
|
532
|
-
|
|
533
|
-
if (isOn !==
|
|
534
|
-
return { found: true, state:
|
|
535
|
-
}
|
|
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
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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
|
-
|
|
558
|
-
if (setter) setter.call(input,
|
|
559
|
-
else input.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
|
-
}
|
|
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
|
-
"
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
656
|
+
var input = item.querySelector("input[type='file']");
|
|
638
657
|
if (!input) return { found: false };
|
|
639
|
-
|
|
658
|
+
var marker = '__sz_upload_' + Date.now();
|
|
640
659
|
input.setAttribute("data-sz-marker", marker);
|
|
641
|
-
return { found: true, marker };
|
|
642
|
-
}
|
|
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.
|
|
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
|
-
|
|
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.
|
|
1848
|
+
const pages = await this._safeGetPages();
|
|
1697
1849
|
let matched = null;
|
|
1698
1850
|
let matchReason = "";
|
|
1699
1851
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type": "module"}
|