@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
|
@@ -215,13 +215,18 @@ AI 直接构造测试计划 JSON:
|
|
|
215
215
|
| click | 点击元素(兼容) | `selector`, `waitForNavigation` | `{ clicked, tagName }` |
|
|
216
216
|
| type | 输入文本(兼容) | `selector`, `text`, `clear` | `{ typed, value }` |
|
|
217
217
|
| waitFor | 等待条件 | `selector` 或 `url`, `timeout` | `{ matched, waited }` |
|
|
218
|
-
| evaluate | 执行 JS | `expression` | `{ result }` |
|
|
218
|
+
| evaluate | 执行 JS(浏览器端) | `expression`, `expectResult?`, `expectResultContains?`, `failOnFalsy?` | `{ result, passed, reason }` |
|
|
219
219
|
| checkElement | 元素断言(兼容) | `selector`, `expect: {visible, minCount, maxCount, hasText}` | `{ passed, checks, elementCount }` |
|
|
220
220
|
| findPage | 查找已打开的标签页 | `urlIncludes` 或 `urlRegex` 或 `titleIncludes` | `{ found, url, title, totalPages }` |
|
|
221
|
-
| findFrame | 查找微前端 iframe | `
|
|
221
|
+
| findFrame | 查找微前端 iframe | `frameSelector`(CSS选择器) 或 `frameUrlContains` 或 `frameContentContains` 或 `frameIndex` | `{ found, frameUrl, matchedBy, totalFrames }` |
|
|
222
222
|
| loginWait | 等待 SSO 登录完成 | `loginSelector`, `timeout`, `interval` | `{ loggedIn, elapsed, currentUrl }` |
|
|
223
223
|
| aiAssert | 语义断言(截图+审查) | `assertion`, `filename` | `{ screenshotPath, assertion, needsVisualReview }` |
|
|
224
224
|
| runTask | 高层任务 runner(批量动态菜单扫描) | `task: "menu-api-scan"`, `menuApiPattern`, `menuLimit`, `menuPathPrefix[]`, `menuFilter[]`, `drawerTriggerSelector`, `useNativeHover`, `apiCaptureOptions`, `continueOnFail` | `{ task, discovered, attempted, byOutcome, results[] }` |
|
|
225
|
+
| **apiCall** | **API 直调(绕过 UI)** | `method`, `url`, `body?`, `headers?` | `{ status, data, passed }` |
|
|
226
|
+
| **switchPage** | **多标签页切换** | `urlContains?`/`titleIncludes?`/`pageIndex?` | `{ passed, url, title }` |
|
|
227
|
+
| **cdpScript** | **调用内置/已注册脚本** | `script`, `params?` | `{ acted, passed, ...result }` |
|
|
228
|
+
| **rawScript** | **Node.js 端执行任意 async** | `code`, `params?` | `{ acted, passed, ...result }` |
|
|
229
|
+
| **registerCdpScript** | **注册可复用脚本** | `name`, `code` | `{ registered, passed }` |
|
|
225
230
|
|
|
226
231
|
### 场景选择策略
|
|
227
232
|
|
|
@@ -246,14 +251,33 @@ AI 直接构造测试计划 JSON:
|
|
|
246
251
|
|
|
247
252
|
### 构造步骤的指导原则
|
|
248
253
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
-
|
|
255
|
-
|
|
256
|
-
|
|
254
|
+
**⚠️ 强制流程:构造 test-case JSON 前必须先执行 DOM 预检查**
|
|
255
|
+
|
|
256
|
+
1. **observe 检查目标页面 DOM 结构**
|
|
257
|
+
- 确认 frame 结构(主页面 + 子应用 iframe)
|
|
258
|
+
- 确认表格/表单/按钮的 selector
|
|
259
|
+
- 确认 setup.findFrame 的匹配方式(frameSelector/frameUrlContains)
|
|
260
|
+
|
|
261
|
+
2. **evaluate 检查关键元素文字**
|
|
262
|
+
- 按钮文字(如“提 交”中间有空格,不能用“提交”匹配)
|
|
263
|
+
- Select 选项文字(如“AI知识主题库”可能与 UI 显示不一致)
|
|
264
|
+
- 表单字段 label(如“数据资源名称”vs“数据集名称”)
|
|
265
|
+
- 表格列头(确认列数和列名)
|
|
266
|
+
|
|
267
|
+
3. **根据 DOM 检查结果构造 test-case JSON**
|
|
268
|
+
- selector 从 observe 获取(不猜测)
|
|
269
|
+
- 选项文字从 evaluate 获取(不猜测)
|
|
270
|
+
- 按钮文字从 evaluate 获取(注意空格)
|
|
271
|
+
- 无项目代码时,DOM 检查替代代码读取
|
|
272
|
+
|
|
273
|
+
4. **act 文字匹配**
|
|
274
|
+
- `text=新建` → 模糊匹配(includes),可能误匹配“新建目录”
|
|
275
|
+
- `text==新建` → 精确匹配(===),只匹配文字恰好为“新建”的元素
|
|
276
|
+
- 按钮文字可能有空格(如“提 交”),evaluate 检查后再选择匹配方式
|
|
277
|
+
|
|
278
|
+
5. **API 路径从代码中的接口调用读取,或从 API 文档获取**
|
|
279
|
+
6. **checkElement 的 expect 从代码逻辑推断**
|
|
280
|
+
7. **compare 的 regions 从设计稿分析结果或组件布局推断**
|
|
257
281
|
|
|
258
282
|
## 微前端测试指南(实战经验)
|
|
259
283
|
|
|
@@ -692,11 +716,15 @@ cd ~/.szcd-mcp/deps && npm install --registry=https://registry.npmmirror.com --@
|
|
|
692
716
|
| `formFill` | 批量填充 + 可选 `validateAfter` | `fields[]` |
|
|
693
717
|
| `closeAllDropdowns` | 关闭所有下拉浮层 | - |
|
|
694
718
|
|
|
695
|
-
|
|
696
|
-
- Ant Select 必须 `mousedown`
|
|
697
|
-
- 选项异步渲染,内置
|
|
698
|
-
-
|
|
699
|
-
-
|
|
719
|
+
**关键约束(6/22 反馈后更新)**:
|
|
720
|
+
- Ant Select 必须 `mousedown` + `click` 触发(click 触发 React onChange)
|
|
721
|
+
- 选项异步渲染,内置 poll 轮询(timeout 默认 3s,不限制 1.5s)
|
|
722
|
+
- `closeAllDropdowns` 去掉 `body >` 限制(适配微前端浮层)+ 增加 300ms sleep
|
|
723
|
+
- 选项点击后 mousedown + mouseup + **click**(三步组合)
|
|
724
|
+
- VERIFY 全局兜底:marker 丢失时全局搜索 `.ant-select-selection-item`
|
|
725
|
+
- VERIFY 检查 `title` + `innerText` + `textContent`(三重兜底)
|
|
726
|
+
- `popupResolved` 变量提升到 for 循环外(避免作用域 bug)
|
|
727
|
+
- 浮层选择器去掉 `body >` 限制(`.ant-select-dropdown` 而非 `body > .ant-select-dropdown`)
|
|
700
728
|
|
|
701
729
|
```json
|
|
702
730
|
{ "type": "antSelect", "field": "数据集名称", "option": "测试集A" }
|
|
@@ -732,6 +760,75 @@ node local-browser-executor.js --action test-case --test-case-file /tmp/case.jso
|
|
|
732
760
|
}
|
|
733
761
|
```
|
|
734
762
|
|
|
763
|
+
## 脚本执行能力分层
|
|
764
|
+
|
|
765
|
+
| 层级 | 步骤类型 | 执行环境 | 能力 | 适用场景 |
|
|
766
|
+
|------|---------|---------|------|---------|
|
|
767
|
+
| 1 | evaluate | 浏览器端 | DOM 查询/操作 | 简单检查、点击元素 |
|
|
768
|
+
| 2 | rawScript | Node.js 端 | 完整 puppeteer API | 跨 frame、等待、多标签页 |
|
|
769
|
+
| 3 | cdpScript | Node.js 端 | 调用已注册脚本 | 复用通用交互模式 |
|
|
770
|
+
| 4 | 内置 cdpScript | Node.js 端 | 预定义通用 UI 交互 | Drawer/Modal/表格等 |
|
|
771
|
+
|
|
772
|
+
**evaluate**:浏览器端执行 JS,只能操作 DOM,不能等待/跨 frame/调用 puppeteer
|
|
773
|
+
**rawScript**:Node.js 端执行 async 函数,接收 `(page, frame, params, browser)`,可调用所有 puppeteer API
|
|
774
|
+
**registerCdpScript**:注册可复用脚本,后续通过 cdpScript 步骤按 name 调用
|
|
775
|
+
**cdpScript**:调用 registerCdpScript 注册的脚本或内置脚本
|
|
776
|
+
|
|
777
|
+
### 内置 cdpScript(通用 UI 交互模式)
|
|
778
|
+
|
|
779
|
+
| script | 功能 | params |
|
|
780
|
+
|--------|------|--------|
|
|
781
|
+
| `drawer-action` | Drawer 操作 | `action: 'search'\|'select'\|'confirm'\|'cancel'`, `radioIndex?`, `searchText?`, `searchPlaceholder?` |
|
|
782
|
+
| `modal-action` | Modal/Popconfirm 操作 | `action: 'confirm'\|'cancel'`, `buttonText?` |
|
|
783
|
+
| `switch-tab` | 多标签页切换 | `urlContains?`, `titleIncludes?`, `pageIndex?` |
|
|
784
|
+
| `wait-api` | 等待 API 响应 | `urlPattern`, `timeout?`, `method?` |
|
|
785
|
+
| `get-table-data` | 提取表格数据 | `selector?`, `maxRows?` |
|
|
786
|
+
|
|
787
|
+
**设计原则**:cdpScript 内置通用 UI 交互模式(跨项目复用),业务流程由 LLM 用 rawScript 动态生成
|
|
788
|
+
|
|
789
|
+
```json
|
|
790
|
+
// 内置 cdpScript 示例
|
|
791
|
+
{ "type": "cdpScript", "script": "get-table-data", "params": { "maxRows": 5 } }
|
|
792
|
+
{ "type": "cdpScript", "script": "drawer-action", "params": { "action": "select", "radioIndex": 0 } }
|
|
793
|
+
{ "type": "cdpScript", "script": "drawer-action", "params": { "action": "confirm" } }
|
|
794
|
+
{ "type": "cdpScript", "script": "modal-action", "params": { "action": "confirm" } }
|
|
795
|
+
{ "type": "cdpScript", "script": "switch-tab", "params": { "urlContains": "localhost:8088" } }
|
|
796
|
+
|
|
797
|
+
// rawScript 示例(LLM 动态生成业务逻辑)
|
|
798
|
+
{ "type": "rawScript", "code": "async (page, frame, params, browser) => { var count = await frame.evaluate(() => document.querySelectorAll('tr').length); return { acted: true, passed: count > 0, rowCount: count, status: 'PASS' } }" }
|
|
799
|
+
|
|
800
|
+
// registerCdpScript + cdpScript(先注册再调用)
|
|
801
|
+
{ "type": "registerCdpScript", "name": "my-check", "code": "async (page, frame, params) => { ... }" }
|
|
802
|
+
{ "type": "cdpScript", "script": "my-check", "params": { "id": "123" } }
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
### evaluate 增强
|
|
806
|
+
|
|
807
|
+
```json
|
|
808
|
+
// expectResult:精确匹配返回值
|
|
809
|
+
{ "type": "evaluate", "expression": "...", "expectResult": "found" }
|
|
810
|
+
|
|
811
|
+
// expectResultContains:包含匹配返回值
|
|
812
|
+
{ "type": "evaluate", "expression": "...", "expectResultContains": "数据资源" }
|
|
813
|
+
|
|
814
|
+
// failOnFalsy:返回 falsy 时标记 FAIL
|
|
815
|
+
{ "type": "evaluate", "expression": "...", "failOnFalsy": true }
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
### apiCall(API 直调)
|
|
819
|
+
|
|
820
|
+
```json
|
|
821
|
+
{ "type": "apiCall", "method": "POST", "url": "http://localhost:8088/api/submit", "body": { "id": "123" } }
|
|
822
|
+
// → { status: 200, data: { code: "200" }, passed: true }
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
### switchPage(多标签页切换)
|
|
826
|
+
|
|
827
|
+
```json
|
|
828
|
+
{ "type": "switchPage", "urlContains": "localhost:8088" }
|
|
829
|
+
// → { passed: true, url: "http://localhost:8088/...", title: "..." }
|
|
830
|
+
```
|
|
831
|
+
|
|
735
832
|
## 语义断言(expect)
|
|
736
833
|
|
|
737
834
|
| assertion | 功能 |
|
|
@@ -762,20 +859,26 @@ node local-browser-executor.js --action test-case --test-case-file /tmp/case.jso
|
|
|
762
859
|
{
|
|
763
860
|
"title": "数据集创建 P0",
|
|
764
861
|
"setup": {
|
|
765
|
-
"findFrame": { "
|
|
862
|
+
"findFrame": { "frameUrlContains": "#/catalog/" }
|
|
766
863
|
},
|
|
767
864
|
"dataset": [{ "name": "A" }, { "name": "B" }],
|
|
768
865
|
"steps": [
|
|
769
|
-
{ "type": "
|
|
866
|
+
{ "type": "navigate", "url": "http://localhost:8088/#/szc-ai-data/catalog/catalog-manage", "waitUntil": "domcontentloaded" },
|
|
867
|
+
{ "type": "expect", "assertion": "locator.toBeVisible", "selector": ".ant-table", "timeout": 20000 },
|
|
770
868
|
{ "type": "antInput", "field": "名称", "value": "{{name}}" },
|
|
771
869
|
{ "type": "antSelect", "field": "类型", "option": "图像" },
|
|
772
|
-
{ "type": "act", "click": "text
|
|
870
|
+
{ "type": "act", "click": "text==提 交" },
|
|
773
871
|
{ "type": "expect", "assertion": "validation.toPass" },
|
|
774
|
-
{ "type": "expect", "assertion": "
|
|
872
|
+
{ "type": "expect", "assertion": "locator.toBeVisible", "selector": ".ant-table", "timeout": 15000 }
|
|
775
873
|
]
|
|
776
874
|
}
|
|
777
875
|
```
|
|
778
876
|
|
|
877
|
+
**findFrame 三种方案(按稳定性排序)**:
|
|
878
|
+
- `frameSelector: "iframe"` — 基于 DOM 选择器(最稳定,推荐)
|
|
879
|
+
- `frameUrlContains: "#/catalog/"` — 基于 URL 子串(navigate 后可能失效)
|
|
880
|
+
- `frameContentContains: "数据集目录"` — 基于内容文字(页面未加载时不可用)
|
|
881
|
+
|
|
779
882
|
**关键参数:**
|
|
780
883
|
|
|
781
884
|
- `setup.findFrame.contentContains` — 一次定位子应用 frame
|
|
@@ -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
|
|