browser-opt 1.0.23 → 1.0.25
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/dist/browser-core/agent.d.ts +4 -0
- package/dist/browser-core/agent.d.ts.map +1 -1
- package/dist/browser-core/agent.js +13 -0
- package/dist/browser-core/agent.js.map +1 -1
- package/dist/browser-opt/index.d.ts +1 -1
- package/dist/browser-opt/index.d.ts.map +1 -1
- package/dist/browser-opt/runner/actions/index.d.ts +1 -1
- package/dist/browser-opt/runner/actions/index.d.ts.map +1 -1
- package/dist/browser-opt/runner/actions/index.js +135 -3
- package/dist/browser-opt/runner/actions/index.js.map +1 -1
- package/dist/browser-opt/runner/actions/utils/click-action.d.ts +1 -1
- package/dist/browser-opt/runner/actions/utils/click-action.d.ts.map +1 -1
- package/dist/browser-opt/runner/actions/utils/click-action.js +10 -4
- package/dist/browser-opt/runner/actions/utils/click-action.js.map +1 -1
- package/dist/browser-opt/runner/actions/utils/date-action.d.ts +1 -1
- package/dist/browser-opt/runner/actions/utils/date-action.d.ts.map +1 -1
- package/dist/browser-opt/runner/actions/utils/date-action.js +5 -1
- package/dist/browser-opt/runner/actions/utils/date-action.js.map +1 -1
- package/dist/browser-opt/runner/actions/utils/dom-action.d.ts +14 -3
- package/dist/browser-opt/runner/actions/utils/dom-action.d.ts.map +1 -1
- package/dist/browser-opt/runner/actions/utils/dom-action.js +301 -20
- package/dist/browser-opt/runner/actions/utils/dom-action.js.map +1 -1
- package/dist/browser-opt/runner/actions/utils/fill-action.d.ts +1 -1
- package/dist/browser-opt/runner/actions/utils/fill-action.d.ts.map +1 -1
- package/dist/browser-opt/runner/actions/utils/fill-action.js +27 -7
- package/dist/browser-opt/runner/actions/utils/fill-action.js.map +1 -1
- package/dist/browser-opt/runner/actions/utils/select-option-action.d.ts +1 -1
- package/dist/browser-opt/runner/actions/utils/select-option-action.d.ts.map +1 -1
- package/dist/browser-opt/runner/actions/utils/select-option-action.js +467 -82
- package/dist/browser-opt/runner/actions/utils/select-option-action.js.map +1 -1
- package/dist/browser-opt/runner/actions/utils/table-row-checkbox-action.d.ts +2 -2
- package/dist/browser-opt/runner/actions/utils/table-row-checkbox-action.d.ts.map +1 -1
- package/dist/browser-opt/runner/actions/utils/table-row-checkbox-action.js +79 -19
- package/dist/browser-opt/runner/actions/utils/table-row-checkbox-action.js.map +1 -1
- package/dist/browser-opt/runner/actions/utils/upload-action.d.ts +8 -1
- package/dist/browser-opt/runner/actions/utils/upload-action.d.ts.map +1 -1
- package/dist/browser-opt/runner/actions/utils/upload-action.js +517 -64
- package/dist/browser-opt/runner/actions/utils/upload-action.js.map +1 -1
- package/dist/browser-opt/runner/evidence.d.ts.map +1 -1
- package/dist/browser-opt/runner/evidence.js +2 -1
- package/dist/browser-opt/runner/evidence.js.map +1 -1
- package/dist/browser-opt/runner/index.d.ts.map +1 -1
- package/dist/browser-opt/runner/index.js +7 -0
- package/dist/browser-opt/runner/index.js.map +1 -1
- package/dist/browser-opt/runner/step-executor.d.ts.map +1 -1
- package/dist/browser-opt/runner/step-executor.js +39 -8
- package/dist/browser-opt/runner/step-executor.js.map +1 -1
- package/dist/browser-opt/type.d.ts +29 -0
- package/dist/browser-opt/type.d.ts.map +1 -1
- package/dist/browser-opt/utils.d.ts.map +1 -1
- package/dist/browser-opt/utils.js +204 -19
- package/dist/browser-opt/utils.js.map +1 -1
- package/dist/cli/utils/output.d.ts.map +1 -1
- package/dist/cli/utils/output.js +11 -0
- package/dist/cli/utils/output.js.map +1 -1
- package/package.json +1 -1
- package/skills/browser-opt/SKILL.md +10 -2
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { findSelectableFieldRef, findSelectableOption, } from '../../../utils.js';
|
|
2
2
|
import { captureTransientSnapshot } from '../../evidence.js';
|
|
3
|
+
import { selectFieldScopedDomTarget, verifyFieldScopedDomSelection } from './dom-action.js';
|
|
3
4
|
import { executeDateSelectOptionAction, resolveDateSelectOption, verifyDateSelectOptionActionEffect, } from './date-action.js';
|
|
4
|
-
/**
|
|
5
|
+
/** 选择前按需滚动目标,包含 switch、原生单选复选框和长表单滚动搜索兜底。 */
|
|
5
6
|
export function executeSelectOptionAction(agent, action, snapshot, options) {
|
|
7
|
+
if (action.rowNumber && action.field) {
|
|
8
|
+
const scoped = selectFieldScopedDomTarget(agent, action.field, action.option, action.rowNumber, action.mode);
|
|
9
|
+
if (scoped?.output) {
|
|
10
|
+
return scoped.output;
|
|
11
|
+
}
|
|
12
|
+
if (scoped?.dropdownOpened) {
|
|
13
|
+
const dropdown = clickDropdownDomTarget(agent, action.field, action.option, true);
|
|
14
|
+
if (dropdown.output) {
|
|
15
|
+
return dropdown.output;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
throw new Error(`无法找到选项:第 ${action.rowNumber} 行 ${action.field} -> ${action.option}`);
|
|
19
|
+
}
|
|
6
20
|
const normalizedDate = resolveDateSelectOption(action);
|
|
7
21
|
if (normalizedDate) {
|
|
8
22
|
return executeDateSelectOptionAction(agent, action, snapshot, normalizedDate);
|
|
@@ -10,26 +24,34 @@ export function executeSelectOptionAction(agent, action, snapshot, options) {
|
|
|
10
24
|
let option = findSelectableOption(snapshot, action.field, action.option);
|
|
11
25
|
let searchSnapshot = snapshot;
|
|
12
26
|
const fieldLabel = action.field ?? '选项';
|
|
13
|
-
if (option.alreadySelected) {
|
|
14
|
-
if (!isSwitchSelectable(option.role)) {
|
|
15
|
-
return `selection skipped: ${fieldLabel} 已是 ${action.option}`;
|
|
16
|
-
}
|
|
27
|
+
if (option.alreadySelected && isSwitchSelectable(option.role)) {
|
|
17
28
|
const domState = action.field ? verifySwitchDomState(agent, action.field, action.option) : null;
|
|
18
29
|
if (domState === true) {
|
|
19
30
|
return `selection skipped: ${fieldLabel} 已是 ${action.option}`;
|
|
20
31
|
}
|
|
21
32
|
option = { ...option, alreadySelected: false };
|
|
22
33
|
}
|
|
23
|
-
const
|
|
24
|
-
|
|
34
|
+
const switchField = action.field;
|
|
35
|
+
const switchDomTarget = switchField && shouldUseSwitchDomFallback(snapshot, action, option.role)
|
|
36
|
+
? clickSwitchDomTarget(agent, switchField, action.option)
|
|
25
37
|
: null;
|
|
26
38
|
if (switchDomTarget) {
|
|
27
39
|
return switchDomTarget;
|
|
28
40
|
}
|
|
29
|
-
const
|
|
41
|
+
const hasExpandableField = Boolean(findSelectableFieldRef(searchSnapshot, action.field));
|
|
42
|
+
const shouldSearchNativeDom = /radio|checkbox/i.test(option.role ?? '') || !hasExpandableField;
|
|
43
|
+
const nativeSelectableDomTarget = shouldSearchNativeDom
|
|
44
|
+
? clickNativeSelectableDomTarget(agent, action)
|
|
45
|
+
: null;
|
|
30
46
|
if (nativeSelectableDomTarget) {
|
|
31
47
|
return nativeSelectableDomTarget;
|
|
32
48
|
}
|
|
49
|
+
if (option.alreadySelected && action.mode !== 'deselect') {
|
|
50
|
+
return `selection skipped: ${fieldLabel} 已是 ${action.option}`;
|
|
51
|
+
}
|
|
52
|
+
if (!option.alreadySelected && action.mode === 'deselect' && option.ref) {
|
|
53
|
+
return `selection skipped: ${fieldLabel} 已取消 ${action.option}`;
|
|
54
|
+
}
|
|
33
55
|
const searchOutput = [];
|
|
34
56
|
const dropdownDomTarget = action.field ? clickDropdownDomTarget(agent, action.field, action.option) : { attempted: false, output: null };
|
|
35
57
|
if (dropdownDomTarget.output) {
|
|
@@ -38,6 +60,7 @@ export function executeSelectOptionAction(agent, action, snapshot, options) {
|
|
|
38
60
|
if (!option.ref) {
|
|
39
61
|
const fieldRef = findSelectableFieldRef(searchSnapshot, action.field);
|
|
40
62
|
if (fieldRef) {
|
|
63
|
+
agent.scrollIntoView(fieldRef);
|
|
41
64
|
searchOutput.push(`open select @${fieldRef}\n${agent.click(fieldRef)}`.trim());
|
|
42
65
|
agent.waitMs(300);
|
|
43
66
|
const openedSnapshot = captureTransientSnapshot(agent);
|
|
@@ -52,9 +75,16 @@ export function executeSelectOptionAction(agent, action, snapshot, options) {
|
|
|
52
75
|
searchOutput.push(...scrolled.logs);
|
|
53
76
|
}
|
|
54
77
|
}
|
|
78
|
+
if (/radio|checkbox/i.test(option.role ?? '')) {
|
|
79
|
+
const revealedNativeTarget = clickNativeSelectableDomTarget(agent, action);
|
|
80
|
+
if (revealedNativeTarget) {
|
|
81
|
+
return [...searchOutput, revealedNativeTarget].filter(Boolean).join('\n').trim();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
55
84
|
if (!option.ref) {
|
|
56
85
|
const fieldRef = findSelectableFieldRef(searchSnapshot, action.field);
|
|
57
86
|
if (fieldRef) {
|
|
87
|
+
agent.scrollIntoView(fieldRef);
|
|
58
88
|
searchOutput.push(`open select @${fieldRef}\n${agent.click(fieldRef)}`.trim());
|
|
59
89
|
agent.waitMs(300);
|
|
60
90
|
const openedSnapshot = captureTransientSnapshot(agent);
|
|
@@ -64,6 +94,10 @@ export function executeSelectOptionAction(agent, action, snapshot, options) {
|
|
|
64
94
|
if (!option.ref) {
|
|
65
95
|
throw new Error(`无法找到选项:${fieldLabel} -> ${action.option}`);
|
|
66
96
|
}
|
|
97
|
+
if (action.mode === 'exclusive' && /checkbox/i.test(option.role ?? '')) {
|
|
98
|
+
throw new Error(`无法可靠执行“仅勾选”:未能在 DOM 中确认 ${fieldLabel} 的复选框分组`);
|
|
99
|
+
}
|
|
100
|
+
agent.scrollIntoView(option.ref);
|
|
67
101
|
const output = agent.click(option.ref);
|
|
68
102
|
const dropdownCleanup = isDropdownOption(option.role) ? dismissActiveDropdown(agent) : null;
|
|
69
103
|
return [
|
|
@@ -75,20 +109,37 @@ export function executeSelectOptionAction(agent, action, snapshot, options) {
|
|
|
75
109
|
}
|
|
76
110
|
/** 对选择动作做后置状态确认,防止命令发出但页面没有完成目标状态时误报成功。 */
|
|
77
111
|
export function verifySelectOptionActionEffect(agent, action, afterSnapshot) {
|
|
112
|
+
if (action.rowNumber && action.field) {
|
|
113
|
+
const reached = verifyFieldScopedDomSelection(agent, action.field, action.option, action.rowNumber, action.mode);
|
|
114
|
+
const target = `第 ${action.rowNumber} 行${action.field}=${action.option}`;
|
|
115
|
+
return reached === true
|
|
116
|
+
? { passed: true, message: `已通过 DOM 确认选择状态:${target}` }
|
|
117
|
+
: { passed: false, message: `DOM 未确认目标行选择状态:${target}` };
|
|
118
|
+
}
|
|
78
119
|
const normalizedDate = resolveDateSelectOption(action);
|
|
79
120
|
if (normalizedDate) {
|
|
80
121
|
return verifyDateSelectOptionActionEffect(agent, action, afterSnapshot, normalizedDate);
|
|
81
122
|
}
|
|
82
123
|
const selected = findSelectableOption(afterSnapshot, action.field, action.option);
|
|
83
|
-
|
|
84
|
-
|
|
124
|
+
const switchField = action.field;
|
|
125
|
+
if (switchField && shouldUseSwitchDomFallback(afterSnapshot, action, selected.role)) {
|
|
126
|
+
const domState = verifySwitchDomState(agent, switchField, action.option);
|
|
85
127
|
if (domState === true) {
|
|
86
|
-
return { passed: true, message: `已确认开关状态:${
|
|
128
|
+
return { passed: true, message: `已确认开关状态:${switchField}=${action.option}` };
|
|
87
129
|
}
|
|
88
130
|
if (domState === false) {
|
|
89
|
-
return { passed: false, message: `DOM 确认开关未达到目标状态:${
|
|
131
|
+
return { passed: false, message: `DOM 确认开关未达到目标状态:${switchField}=${action.option}` };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (/radio|checkbox/i.test(selected.role ?? '') || action.mode === 'deselect' || action.mode === 'exclusive') {
|
|
135
|
+
const domState = verifyNativeSelectableDomState(agent, action);
|
|
136
|
+
if (domState === true) {
|
|
137
|
+
const intent = action.mode === 'exclusive' ? '仅勾选' : action.mode === 'deselect' ? '取消勾选' : '勾选';
|
|
138
|
+
return { passed: true, message: `已通过 DOM 确认${intent}状态:${action.field ?? '选项'}=${action.option}` };
|
|
139
|
+
}
|
|
140
|
+
if (domState === false || action.mode === 'deselect' || action.mode === 'exclusive') {
|
|
141
|
+
return { passed: false, message: `DOM 确认复选状态未达到目标:${action.field ?? '选项'}=${action.option}` };
|
|
90
142
|
}
|
|
91
|
-
return { passed: false, message: `无法可靠确认开关状态:${action.field}=${action.option}` };
|
|
92
143
|
}
|
|
93
144
|
if (selected.alreadySelected) {
|
|
94
145
|
return { passed: true, message: `已确认选择状态:${action.field ?? '选项'}=${action.option}` };
|
|
@@ -99,55 +150,231 @@ export function verifySelectOptionActionEffect(agent, action, afterSnapshot) {
|
|
|
99
150
|
if (isOrdinalOptionIntent(action.option) && action.field && isFieldSelectedValueVisible(afterSnapshot.text, action.field)) {
|
|
100
151
|
return { passed: true, message: `已确认按位置选择:${action.field}=${action.option}` };
|
|
101
152
|
}
|
|
153
|
+
if (action.field && verifyDropdownDomState(agent, action.field, action.option) === true) {
|
|
154
|
+
return { passed: true, message: `已通过 DOM 确认选择状态:${action.field}=${action.option}` };
|
|
155
|
+
}
|
|
102
156
|
return { passed: false, message: `动作后未确认目标已选中:${action.field ?? '选项'}=${action.option}` };
|
|
103
157
|
}
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
158
|
+
/**
|
|
159
|
+
* radio/checkbox 可能位于当前交互快照之外,直接从完整 DOM 按字段和选项定位真实 label。
|
|
160
|
+
* 定位成功后先滚入视口再点击,既覆盖长表单,也避免无障碍 ref 指向隐藏 input。
|
|
161
|
+
*/
|
|
162
|
+
function clickNativeSelectableDomTarget(agent, action) {
|
|
163
|
+
const field = action.field;
|
|
164
|
+
const option = action.option;
|
|
165
|
+
const mode = action.mode ?? 'select';
|
|
109
166
|
const script = `(() => {
|
|
110
167
|
const normalize = (value) => (value || '').replace(/\\s+/g, '').trim();
|
|
168
|
+
const isVisible = (element) => {
|
|
169
|
+
const style = window.getComputedStyle(element);
|
|
170
|
+
const rect = element.getBoundingClientRect();
|
|
171
|
+
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
|
172
|
+
};
|
|
111
173
|
const target = normalize(${JSON.stringify(option)});
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
174
|
+
const field = normalize(${JSON.stringify(field)});
|
|
175
|
+
const mode = ${JSON.stringify(mode)};
|
|
176
|
+
const candidates = [...document.querySelectorAll('label')]
|
|
177
|
+
.map((label) => ({
|
|
178
|
+
label,
|
|
179
|
+
input: label.querySelector('input[type="radio"], input[type="checkbox"]'),
|
|
180
|
+
}))
|
|
181
|
+
.filter(({ label, input }) => input && isVisible(label) && normalize(label.textContent) === target);
|
|
182
|
+
if (candidates.length === 0) {
|
|
183
|
+
return JSON.stringify({ matchedCount: 0, clicked: false });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const fieldNodes = field
|
|
187
|
+
? [...document.querySelectorAll('label, [class*="label"], [class*="form-item"], [class*="formItem"], dt, th')]
|
|
188
|
+
.filter((element) => isVisible(element) && normalize(element.textContent).includes(field))
|
|
189
|
+
: [];
|
|
190
|
+
const distance = (left, right) => {
|
|
191
|
+
const a = left.getBoundingClientRect();
|
|
192
|
+
const b = right.getBoundingClientRect();
|
|
193
|
+
return Math.abs((a.left + a.right - b.left - b.right) / 2)
|
|
194
|
+
+ Math.abs((a.top + a.bottom - b.top - b.bottom) / 2);
|
|
195
|
+
};
|
|
196
|
+
const scoped = candidates
|
|
197
|
+
.map((candidate) => ({
|
|
198
|
+
...candidate,
|
|
199
|
+
fieldDistance: fieldNodes.length > 0
|
|
200
|
+
? Math.min(...fieldNodes.map((fieldNode) => distance(fieldNode, candidate.label)))
|
|
201
|
+
: 0,
|
|
202
|
+
}))
|
|
203
|
+
.sort((a, b) => a.fieldDistance - b.fieldDistance);
|
|
204
|
+
if (scoped.length > 1 && (!field || scoped[0].fieldDistance === scoped[1].fieldDistance)) {
|
|
205
|
+
return JSON.stringify({ matchedCount: scoped.length, clicked: false, ambiguous: true });
|
|
115
206
|
}
|
|
116
|
-
|
|
207
|
+
|
|
208
|
+
const { label, input } = scoped[0];
|
|
117
209
|
if (!input || input.disabled) {
|
|
118
|
-
return JSON.stringify({ matchedCount:
|
|
210
|
+
return JSON.stringify({ matchedCount: scoped.length, clicked: false, disabled: Boolean(input?.disabled) });
|
|
119
211
|
}
|
|
120
|
-
|
|
121
|
-
|
|
212
|
+
label.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
213
|
+
const group = (() => {
|
|
214
|
+
let current = label.parentElement;
|
|
215
|
+
for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) {
|
|
216
|
+
const checkboxes = [...current.querySelectorAll('input[type="checkbox"]')].filter((item) => !item.disabled);
|
|
217
|
+
if (checkboxes.includes(input) && checkboxes.length > 1 && (!field || normalize(current.textContent).includes(field))) {
|
|
218
|
+
return checkboxes;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return [input];
|
|
222
|
+
})();
|
|
223
|
+
const clickInputLabel = (targetInput) => {
|
|
224
|
+
const targetLabel = targetInput.closest('label') || document.querySelector('label[for="' + CSS.escape(targetInput.id) + '"]');
|
|
225
|
+
(targetLabel || targetInput).click();
|
|
226
|
+
};
|
|
227
|
+
let clickCount = 0;
|
|
228
|
+
if (mode === 'exclusive') {
|
|
229
|
+
if (input.type !== 'checkbox' || group.length < 2) {
|
|
230
|
+
return JSON.stringify({ matchedCount: scoped.length, clicked: false, groupFound: false });
|
|
231
|
+
}
|
|
232
|
+
for (const sibling of group) {
|
|
233
|
+
if (sibling !== input && sibling.checked) {
|
|
234
|
+
clickInputLabel(sibling);
|
|
235
|
+
clickCount += 1;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const desiredChecked = mode !== 'deselect';
|
|
240
|
+
if (input.checked !== desiredChecked) {
|
|
241
|
+
clickInputLabel(input);
|
|
242
|
+
clickCount += 1;
|
|
243
|
+
}
|
|
244
|
+
const otherSelectedCount = group.filter((item) => item !== input && item.checked).length;
|
|
245
|
+
const reached = input.checked === desiredChecked && (mode !== 'exclusive' || otherSelectedCount === 0);
|
|
246
|
+
return JSON.stringify({
|
|
247
|
+
matchedCount: scoped.length,
|
|
248
|
+
clicked: clickCount > 0,
|
|
249
|
+
checked: input.checked,
|
|
250
|
+
alreadySelected: clickCount === 0 && reached,
|
|
251
|
+
groupFound: group.length > 1,
|
|
252
|
+
otherSelectedCount,
|
|
253
|
+
reached,
|
|
254
|
+
});
|
|
122
255
|
})()`;
|
|
123
256
|
try {
|
|
124
257
|
const parsed = parseEvalJson(agent.evaluate(script));
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
258
|
+
const reached = parsed.reached ?? (mode === 'select' && parsed.checked === true);
|
|
259
|
+
if (reached !== true) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
// 页面异步水合期间,复选框可能会先短暂显示为未选中,再恢复到真实已选状态。
|
|
263
|
+
// 已达到目标时仍等待一次重渲染并复查(parseEvalJson),避免把水合瞬间的未选中误判为需要继续点击。
|
|
264
|
+
if (parsed.alreadySelected === true) {
|
|
265
|
+
agent.waitMs(300);
|
|
266
|
+
revealNativeSelectableDomTarget(agent, field, option);
|
|
267
|
+
agent.waitMs(200);
|
|
268
|
+
const settled = parseEvalJson(agent.evaluate(script));
|
|
269
|
+
if (settled.clicked === true && settled.reached === true) {
|
|
270
|
+
agent.waitMs(300);
|
|
271
|
+
revealNativeSelectableDomTarget(agent, field, option);
|
|
272
|
+
agent.waitMs(200);
|
|
273
|
+
return `selectable dom ${mode} ${field ?? '选项'}=${option}`;
|
|
274
|
+
}
|
|
275
|
+
if (settled.alreadySelected !== true || settled.reached !== true) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
return `selectable dom revealed: ${field ?? '选项'} 已达到 ${option}`;
|
|
279
|
+
}
|
|
280
|
+
if (parsed.clicked === true) {
|
|
281
|
+
agent.waitMs(300);
|
|
282
|
+
revealNativeSelectableDomTarget(agent, field, option);
|
|
283
|
+
agent.waitMs(200);
|
|
284
|
+
return `selectable dom ${mode} ${field ?? '选项'}=${option}`;
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
128
287
|
}
|
|
129
288
|
catch {
|
|
130
289
|
return null;
|
|
131
290
|
}
|
|
132
291
|
}
|
|
133
|
-
/**
|
|
134
|
-
function
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
|
|
292
|
+
/** 组件状态更新可能触发表单重渲染并复位滚动位置,动作完成后重新把目标控件滚入视口。 */
|
|
293
|
+
function revealNativeSelectableDomTarget(agent, field, option) {
|
|
294
|
+
const script = `(() => {
|
|
295
|
+
const normalize = (value) => (value || '').replace(/\\s+/g, '').trim();
|
|
296
|
+
const isVisible = (element) => {
|
|
297
|
+
const style = window.getComputedStyle(element);
|
|
298
|
+
const rect = element.getBoundingClientRect();
|
|
299
|
+
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
|
300
|
+
};
|
|
301
|
+
const target = normalize(${JSON.stringify(option)});
|
|
302
|
+
const field = normalize(${JSON.stringify(field)});
|
|
303
|
+
const candidates = [...document.querySelectorAll('label')]
|
|
304
|
+
.filter((label) => label.querySelector('input[type="radio"], input[type="checkbox"]'))
|
|
305
|
+
.filter(isVisible)
|
|
306
|
+
.filter((label) => normalize(label.textContent) === target);
|
|
307
|
+
if (candidates.length === 0) return JSON.stringify({ revealed: false });
|
|
308
|
+
const fieldNodes = field
|
|
309
|
+
? [...document.querySelectorAll('label, [class*="label"], [class*="form-item"], [class*="formItem"], dt, th')]
|
|
310
|
+
.filter((element) => isVisible(element) && normalize(element.textContent).includes(field))
|
|
311
|
+
: [];
|
|
312
|
+
const distance = (left, right) => {
|
|
313
|
+
const a = left.getBoundingClientRect();
|
|
314
|
+
const b = right.getBoundingClientRect();
|
|
315
|
+
return Math.abs((a.left + a.right - b.left - b.right) / 2)
|
|
316
|
+
+ Math.abs((a.top + a.bottom - b.top - b.bottom) / 2);
|
|
317
|
+
};
|
|
318
|
+
const targetLabel = candidates
|
|
319
|
+
.map((label) => ({
|
|
320
|
+
label,
|
|
321
|
+
fieldDistance: fieldNodes.length > 0 ? Math.min(...fieldNodes.map((fieldNode) => distance(fieldNode, label))) : 0,
|
|
322
|
+
}))
|
|
323
|
+
.sort((a, b) => a.fieldDistance - b.fieldDistance)[0]?.label;
|
|
324
|
+
if (!targetLabel) return JSON.stringify({ revealed: false });
|
|
325
|
+
targetLabel.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
|
|
326
|
+
return JSON.stringify({ revealed: true });
|
|
138
327
|
})()`;
|
|
139
328
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
329
|
+
return parseEvalJson(agent.evaluate(script)).revealed === true;
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** 直接读取 DOM 中目标复选框及同组状态,验证“取消”与“仅勾选”的最终语义。 */
|
|
336
|
+
function verifyNativeSelectableDomState(agent, action) {
|
|
337
|
+
const script = `(() => {
|
|
338
|
+
const normalize = (value) => (value || '').replace(/\\s+/g, '').trim();
|
|
339
|
+
const isVisible = (element) => {
|
|
340
|
+
const style = window.getComputedStyle(element);
|
|
341
|
+
const rect = element.getBoundingClientRect();
|
|
342
|
+
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
|
343
|
+
};
|
|
344
|
+
const target = normalize(${JSON.stringify(action.option)});
|
|
345
|
+
const field = normalize(${JSON.stringify(action.field)});
|
|
346
|
+
const mode = ${JSON.stringify(action.mode ?? 'select')};
|
|
347
|
+
const candidates = [...document.querySelectorAll('label')]
|
|
348
|
+
.map((label) => ({ label, input: label.querySelector('input[type="radio"], input[type="checkbox"]') }))
|
|
349
|
+
.filter(({ label, input }) => input && isVisible(label) && normalize(label.textContent) === target);
|
|
350
|
+
if (candidates.length !== 1) return JSON.stringify({ found: false });
|
|
351
|
+
const { label, input } = candidates[0];
|
|
352
|
+
let group = [input];
|
|
353
|
+
for (let current = label.parentElement, depth = 0; current && depth < 8; current = current.parentElement, depth += 1) {
|
|
354
|
+
const checkboxes = [...current.querySelectorAll('input[type="checkbox"]')].filter((item) => !item.disabled);
|
|
355
|
+
if (checkboxes.includes(input) && checkboxes.length > 1 && (!field || normalize(current.textContent).includes(field))) {
|
|
356
|
+
group = checkboxes;
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const desiredChecked = mode !== 'deselect';
|
|
361
|
+
const otherSelectedCount = group.filter((item) => item !== input && item.checked).length;
|
|
362
|
+
return JSON.stringify({
|
|
363
|
+
found: true,
|
|
364
|
+
reached: input.checked === desiredChecked && (mode !== 'exclusive' || group.length > 1 && otherSelectedCount === 0),
|
|
365
|
+
});
|
|
366
|
+
})()`;
|
|
367
|
+
try {
|
|
368
|
+
const parsed = parseEvalJson(agent.evaluate(script));
|
|
369
|
+
return parsed.found === true && typeof parsed.reached === 'boolean' ? parsed.reached : null;
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** 普通下拉优先按 DOM 中字段同组控件和可见弹层选项点击,避开嵌套布局里的 ref 坐标漂移。 */
|
|
376
|
+
function clickDropdownDomTarget(agent, field, option, alreadyOpened = false) {
|
|
377
|
+
const consumeOpenedDropdown = () => {
|
|
151
378
|
const clickScript = `(() => {
|
|
152
379
|
const selectHelper = ${dropdownDomHelperSource()};
|
|
153
380
|
return JSON.stringify(selectHelper.clickVisibleOption(${JSON.stringify(option)}));
|
|
@@ -161,23 +388,64 @@ function clickDropdownDomTarget(agent, field, option) {
|
|
|
161
388
|
const searched = parseEvalJson(agent.evaluate(searchScript));
|
|
162
389
|
if (searched.searched === true) {
|
|
163
390
|
agent.waitMs(500);
|
|
164
|
-
|
|
391
|
+
Object.assign(clicked, parseEvalJson(agent.evaluate(clickScript)));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (clicked.clicked !== true) {
|
|
395
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
396
|
+
const scrollScript = `(() => {
|
|
165
397
|
const selectHelper = ${dropdownDomHelperSource()};
|
|
166
|
-
return JSON.stringify(selectHelper.
|
|
398
|
+
return JSON.stringify(selectHelper.scrollActiveDropdown(${JSON.stringify(option)}));
|
|
167
399
|
})()`;
|
|
168
|
-
|
|
400
|
+
const scrolled = parseEvalJson(agent.evaluate(scrollScript));
|
|
401
|
+
Object.assign(clicked, scrolled);
|
|
402
|
+
if (scrolled.clicked === true || scrolled.scrolled !== true) {
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
agent.waitMs(200);
|
|
169
406
|
}
|
|
170
407
|
}
|
|
171
|
-
if (clicked.clicked
|
|
172
|
-
|
|
173
|
-
const selectedText = clicked.selectedText ? ` (${clicked.selectedText})` : '';
|
|
174
|
-
const dropdownCleanup = dismissActiveDropdown(agent);
|
|
175
|
-
return {
|
|
176
|
-
attempted: true,
|
|
177
|
-
output: [`select dom click ${field}=${option}${selectedText}`, dropdownCleanup].filter(Boolean).join('\n'),
|
|
178
|
-
};
|
|
408
|
+
if (clicked.clicked !== true) {
|
|
409
|
+
return { attempted: true, output: null };
|
|
179
410
|
}
|
|
180
|
-
|
|
411
|
+
agent.waitMs(300);
|
|
412
|
+
const selectedText = clicked.selectedText ? ` (${clicked.selectedText})` : '';
|
|
413
|
+
const dropdownCleanup = dismissActiveDropdown(agent);
|
|
414
|
+
return {
|
|
415
|
+
attempted: true,
|
|
416
|
+
output: [`select dom click ${field}=${option}${selectedText}`, dropdownCleanup].filter(Boolean).join('\n'),
|
|
417
|
+
};
|
|
418
|
+
};
|
|
419
|
+
const visibleDropdownScript = `(() => {
|
|
420
|
+
const selectHelper = ${dropdownDomHelperSource()};
|
|
421
|
+
return JSON.stringify(selectHelper.hasVisibleDropdown());
|
|
422
|
+
})()`;
|
|
423
|
+
const openScript = `(() => {
|
|
424
|
+
const selectHelper = ${dropdownDomHelperSource()};
|
|
425
|
+
return JSON.stringify(selectHelper.openDropdownByField(${JSON.stringify(field)}));
|
|
426
|
+
})()`;
|
|
427
|
+
try {
|
|
428
|
+
const dropdownVisible = parseEvalJson(agent.evaluate(visibleDropdownScript)).dropdownOpen === true;
|
|
429
|
+
if (dropdownVisible) {
|
|
430
|
+
const consumed = consumeOpenedDropdown();
|
|
431
|
+
if (consumed.output || alreadyOpened) {
|
|
432
|
+
return consumed;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (!alreadyOpened) {
|
|
436
|
+
const opened = parseEvalJson(agent.evaluate(openScript));
|
|
437
|
+
if (typeof opened.opened !== 'boolean') {
|
|
438
|
+
return { attempted: false, output: null };
|
|
439
|
+
}
|
|
440
|
+
if (!opened.found) {
|
|
441
|
+
return { attempted: false, output: null };
|
|
442
|
+
}
|
|
443
|
+
if (opened.opened === false) {
|
|
444
|
+
return { attempted: true, output: null };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
agent.waitMs(300);
|
|
448
|
+
return consumeOpenedDropdown();
|
|
181
449
|
}
|
|
182
450
|
catch (error) {
|
|
183
451
|
if (error instanceof Error && error.message === '选中选项后下拉层仍未收起') {
|
|
@@ -214,6 +482,20 @@ function dismissActiveDropdown(agent) {
|
|
|
214
482
|
}
|
|
215
483
|
return 'dismiss active dropdown';
|
|
216
484
|
}
|
|
485
|
+
/** 快照未暴露组合输入中的下拉值时,读取字段对应下拉的真实 DOM 值做最终确认。 */
|
|
486
|
+
function verifyDropdownDomState(agent, field, option) {
|
|
487
|
+
const script = `(() => {
|
|
488
|
+
const selectHelper = ${dropdownDomHelperSource()};
|
|
489
|
+
return JSON.stringify(selectHelper.verifySelectedValueByField(${JSON.stringify(field)}, ${JSON.stringify(option)}));
|
|
490
|
+
})()`;
|
|
491
|
+
try {
|
|
492
|
+
const parsed = parseEvalJson(agent.evaluate(script));
|
|
493
|
+
return parsed.found === true && typeof parsed.reached === 'boolean' ? parsed.reached : null;
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
217
499
|
/** 长表单中字段可能不在当前可交互快照里,按视口上下搜索字段和目标选项。 */
|
|
218
500
|
function searchSelectableInLongForm(agent, action) {
|
|
219
501
|
const logs = [];
|
|
@@ -241,18 +523,17 @@ function clickSwitchDomTarget(agent, field, option) {
|
|
|
241
523
|
const script = `(() => {
|
|
242
524
|
const switchHelper = ${switchDomHelperSource()};
|
|
243
525
|
const result = switchHelper.findSwitchByField(${JSON.stringify(field)}, ${JSON.stringify(option)});
|
|
244
|
-
|
|
245
|
-
|
|
526
|
+
const element = result.element;
|
|
527
|
+
const { element: ignoredElement, ...evidence } = result;
|
|
528
|
+
if (!result.found || !element || typeof result.desiredChecked !== 'boolean') {
|
|
529
|
+
return JSON.stringify(evidence);
|
|
246
530
|
}
|
|
247
531
|
if (result.checked === result.desiredChecked) {
|
|
248
|
-
return JSON.stringify({ ...
|
|
249
|
-
}
|
|
250
|
-
const element = document.querySelector('[data-browser-opt-switch-id="' + result.switchId + '"]');
|
|
251
|
-
if (!element) {
|
|
252
|
-
return JSON.stringify({ ...result, clicked: false, missingElement: true });
|
|
532
|
+
return JSON.stringify({ ...evidence, clicked: false });
|
|
253
533
|
}
|
|
534
|
+
element.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
254
535
|
element.click();
|
|
255
|
-
return JSON.stringify({ ...
|
|
536
|
+
return JSON.stringify({ ...evidence, clicked: true });
|
|
256
537
|
})()
|
|
257
538
|
`;
|
|
258
539
|
try {
|
|
@@ -282,7 +563,8 @@ function verifySwitchDomState(agent, field, option) {
|
|
|
282
563
|
const script = `(() => {
|
|
283
564
|
const switchHelper = ${switchDomHelperSource()};
|
|
284
565
|
const result = switchHelper.findSwitchByField(${JSON.stringify(field)}, ${JSON.stringify(option)});
|
|
285
|
-
|
|
566
|
+
const { element: ignoredElement, ...evidence } = result;
|
|
567
|
+
return JSON.stringify(evidence);
|
|
286
568
|
})()
|
|
287
569
|
`;
|
|
288
570
|
try {
|
|
@@ -476,7 +758,12 @@ function clickVisibleOption(option) {
|
|
|
476
758
|
? options[ordinalIndex === -1 ? options.length - 1 : ordinalIndex]
|
|
477
759
|
: options
|
|
478
760
|
.filter((item) => item.text.includes(optionText) || optionText.includes(item.text))
|
|
479
|
-
.sort((a, b) =>
|
|
761
|
+
.sort((a, b) => {
|
|
762
|
+
const aExactRank = a.text === optionText ? 0 : 1;
|
|
763
|
+
const bExactRank = b.text === optionText ? 0 : 1;
|
|
764
|
+
return aExactRank - bExactRank
|
|
765
|
+
|| (a.rect.width * a.rect.height) - (b.rect.width * b.rect.height);
|
|
766
|
+
})[0];
|
|
480
767
|
if (!target) {
|
|
481
768
|
return { found: false, clicked: false };
|
|
482
769
|
}
|
|
@@ -492,9 +779,44 @@ function searchActiveDropdown(option) {
|
|
|
492
779
|
if (!(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement)) {
|
|
493
780
|
return { searched: false };
|
|
494
781
|
}
|
|
782
|
+
if (input.readOnly || input.disabled) {
|
|
783
|
+
return { searched: false };
|
|
784
|
+
}
|
|
495
785
|
browserOptInputValue(input, option);
|
|
496
786
|
return { searched: true };
|
|
497
787
|
}
|
|
788
|
+
function scrollActiveDropdown(option) {
|
|
789
|
+
const clicked = clickVisibleOption(option);
|
|
790
|
+
if (clicked.clicked) {
|
|
791
|
+
return clicked;
|
|
792
|
+
}
|
|
793
|
+
const active = document.querySelector('[data-browser-opt-active-select="true"]');
|
|
794
|
+
const activeRect = active?.getBoundingClientRect();
|
|
795
|
+
const root = browserOptDropdownContainers()
|
|
796
|
+
.map((item) => ({ ...item, distance: activeRect ? browserOptCenterDistance(activeRect, item.rect) : 0 }))
|
|
797
|
+
.sort((a, b) => a.distance - b.distance)[0]?.element;
|
|
798
|
+
if (!root) {
|
|
799
|
+
return { found: false, clicked: false, scrolled: false };
|
|
800
|
+
}
|
|
801
|
+
const preferred = root.querySelector('.rc-virtual-list-holder, .ant-select-dropdown-menu, .el-scrollbar__wrap');
|
|
802
|
+
const candidates = [preferred, root, ...root.querySelectorAll('[role="listbox"], [class*="virtual-list"], [class*="scroll"]')]
|
|
803
|
+
.filter((element, index, items) => element instanceof HTMLElement && items.indexOf(element) === index)
|
|
804
|
+
.filter((element) => element.scrollHeight > element.clientHeight + 1);
|
|
805
|
+
const scrollable = candidates[0];
|
|
806
|
+
if (!scrollable) {
|
|
807
|
+
return { found: false, clicked: false, scrolled: false };
|
|
808
|
+
}
|
|
809
|
+
const previousTop = scrollable.scrollTop;
|
|
810
|
+
const maxTop = Math.max(scrollable.scrollHeight - scrollable.clientHeight, 0);
|
|
811
|
+
scrollable.scrollTop = Math.min(previousTop + Math.max(Math.floor(scrollable.clientHeight * 0.8), 120), maxTop);
|
|
812
|
+
scrollable.dispatchEvent(new Event('scroll', { bubbles: true }));
|
|
813
|
+
return {
|
|
814
|
+
found: false,
|
|
815
|
+
clicked: false,
|
|
816
|
+
scrolled: scrollable.scrollTop > previousTop,
|
|
817
|
+
reachedEnd: scrollable.scrollTop >= maxTop,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
498
820
|
function dismissActiveDropdown() {
|
|
499
821
|
const containers = browserOptDropdownContainers();
|
|
500
822
|
if (containers.length === 0) {
|
|
@@ -514,7 +836,46 @@ function dismissActiveDropdown() {
|
|
|
514
836
|
function hasVisibleDropdown() {
|
|
515
837
|
return { dropdownOpen: browserOptDropdownContainers().length > 0 };
|
|
516
838
|
}
|
|
517
|
-
|
|
839
|
+
function verifySelectedValueByField(field, option) {
|
|
840
|
+
const fieldText = normalizeBrowserOptText(field);
|
|
841
|
+
const optionText = normalizeBrowserOptText(option);
|
|
842
|
+
const labels = [...document.querySelectorAll('body *')]
|
|
843
|
+
.filter((element) => browserOptVisible(element) && normalizeBrowserOptText(element.textContent).includes(fieldText))
|
|
844
|
+
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
|
|
845
|
+
.sort((a, b) => (a.rect.width * a.rect.height) - (b.rect.width * b.rect.height));
|
|
846
|
+
for (const label of labels) {
|
|
847
|
+
for (const container of browserOptClosestFieldContainers(label.element)) {
|
|
848
|
+
const targets = browserOptSelects()
|
|
849
|
+
.filter((select) => container.element.contains(select))
|
|
850
|
+
.map((element) => ({ element, distance: browserOptCenterDistance(label.rect, element.getBoundingClientRect()) }))
|
|
851
|
+
.sort((a, b) => a.distance - b.distance);
|
|
852
|
+
for (const target of targets) {
|
|
853
|
+
const element = target.element;
|
|
854
|
+
const nativeValue = element.matches('select')
|
|
855
|
+
? String(element.selectedOptions?.[0]?.textContent || element.value || '')
|
|
856
|
+
: '';
|
|
857
|
+
const inputValue = String(element.querySelector('input:not([type="hidden"])')?.value || '');
|
|
858
|
+
const selectedText = String(element.querySelector([
|
|
859
|
+
'.ant-select-selection-item',
|
|
860
|
+
'.ant-select-selection-selected-value',
|
|
861
|
+
'.el-input__inner',
|
|
862
|
+
'[class*="singleValue"]'
|
|
863
|
+
].join(','))?.textContent || '');
|
|
864
|
+
const values = [nativeValue, inputValue, selectedText]
|
|
865
|
+
.map(normalizeBrowserOptText)
|
|
866
|
+
.filter(Boolean);
|
|
867
|
+
if (values.some((value) => value === optionText)) {
|
|
868
|
+
return { found: true, reached: true, value: optionText };
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (targets.length > 0) {
|
|
872
|
+
return { found: true, reached: false };
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return { found: false };
|
|
877
|
+
}
|
|
878
|
+
return { openDropdownByField, clickVisibleOption, searchActiveDropdown, scrollActiveDropdown, dismissActiveDropdown, hasVisibleDropdown, verifySelectedValueByField };
|
|
518
879
|
})()
|
|
519
880
|
`;
|
|
520
881
|
}
|
|
@@ -558,22 +919,32 @@ const browserOptSwitches = () => [...document.querySelectorAll('[role="switch"],
|
|
|
558
919
|
.filter((element) => browserOptVisible(element) && (element.getAttribute('role') === 'switch' || String(element.className || '').includes('switch')));
|
|
559
920
|
function findSwitchByField(field, option) {
|
|
560
921
|
const fieldText = normalizeBrowserOptText(field);
|
|
922
|
+
// 字段文案只做标准化后的完整匹配,避免相似字段误触发相邻开关。
|
|
561
923
|
const labels = [...document.querySelectorAll('body *')]
|
|
562
|
-
.filter((element) => browserOptVisible(element)
|
|
563
|
-
.map((element) =>
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
});
|
|
924
|
+
.filter((element) => browserOptVisible(element))
|
|
925
|
+
.map((element) => {
|
|
926
|
+
const text = normalizeBrowserOptText(element.textContent);
|
|
927
|
+
return { element, text, rect: element.getBoundingClientRect() };
|
|
928
|
+
})
|
|
929
|
+
.filter((item) => item.text.includes(fieldText))
|
|
930
|
+
.sort((a, b) => a.text.length - b.text.length || (a.rect.width * a.rect.height) - (b.rect.width * b.rect.height));
|
|
931
|
+
const switches = browserOptSwitches().map((element) => ({
|
|
932
|
+
element,
|
|
933
|
+
rect: element.getBoundingClientRect(),
|
|
934
|
+
checked: browserOptSwitchState(element),
|
|
935
|
+
desiredChecked: browserOptSwitchDesiredState(element, option),
|
|
936
|
+
}));
|
|
576
937
|
for (const label of labels) {
|
|
938
|
+
// 优先使用字段所在的最小单开关容器,避免相邻卡片或大表单中的无名 switch 串位。
|
|
939
|
+
let container = label.element.parentElement;
|
|
940
|
+
for (let depth = 0; container && container !== document.body && depth < 6; depth += 1, container = container.parentElement) {
|
|
941
|
+
const contained = switches.filter((item) => container.contains(item.element) && item.checked !== null && item.desiredChecked !== null);
|
|
942
|
+
if (contained.length === 1) {
|
|
943
|
+
const target = contained[0];
|
|
944
|
+
return { found: true, checked: target.checked, desiredChecked: target.desiredChecked, element: target.element };
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
577
948
|
const sameRow = switches
|
|
578
949
|
.map((item) => {
|
|
579
950
|
const verticalOverlap = Math.min(label.rect.bottom, item.rect.bottom) - Math.max(label.rect.top, item.rect.top);
|
|
@@ -585,7 +956,7 @@ function findSwitchByField(field, option) {
|
|
|
585
956
|
.sort((a, b) => a.yDistance - b.yDistance || a.xDistance - b.xDistance);
|
|
586
957
|
const target = sameRow[0];
|
|
587
958
|
if (target) {
|
|
588
|
-
return { found: true, checked: target.checked, desiredChecked: target.desiredChecked,
|
|
959
|
+
return { found: true, checked: target.checked, desiredChecked: target.desiredChecked, element: target.element };
|
|
589
960
|
}
|
|
590
961
|
}
|
|
591
962
|
return { found: false, checked: null, desiredChecked: null };
|
|
@@ -601,6 +972,16 @@ function isSwitchSelectable(role) {
|
|
|
601
972
|
function isDropdownOption(role) {
|
|
602
973
|
return Boolean(role && /option/i.test(role));
|
|
603
974
|
}
|
|
975
|
+
/** 布尔型目标值在真实页面常呈现为无名 switch,先尝试 DOM 同行近邻开关,失败后再回落其他选择策略。 */
|
|
976
|
+
function shouldUseSwitchDomFallback(snapshot, action, role) {
|
|
977
|
+
if (!action.field) {
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
if (isSwitchSelectable(role) || snapshotHasSwitchField(snapshot, action.field)) {
|
|
981
|
+
return true;
|
|
982
|
+
}
|
|
983
|
+
return isBooleanLikeSwitchOption(action.option);
|
|
984
|
+
}
|
|
604
985
|
/** 只有 snapshot 明确把字段暴露为 switch 时,才启用开关 DOM 兜底,避免大表单误碰邻近控件。 */
|
|
605
986
|
function snapshotHasSwitchField(snapshot, field) {
|
|
606
987
|
if (!field) {
|
|
@@ -640,6 +1021,10 @@ function isFieldLabelSnapshotLine(parsed, normalizedField) {
|
|
|
640
1021
|
const normalizedLabel = normalizeVisibleText(parsed.label);
|
|
641
1022
|
return normalizedLabel.includes(normalizedField) && normalizedLabel.length <= normalizedField.length + 8;
|
|
642
1023
|
}
|
|
1024
|
+
/** 与 utils 中的布尔 switch 词表保持一致,供本文件决定是否值得走 switch DOM 兜底。 */
|
|
1025
|
+
function isBooleanLikeSwitchOption(option) {
|
|
1026
|
+
return /^(是|否|开|关|开启|关闭|打开|启用|停用|true|false|yes|no|on|off)$/i.test(option.trim());
|
|
1027
|
+
}
|
|
643
1028
|
/** 下拉选择通常会收起选项列表,只保留字段和值文案,因此补充基于页面文案的确认。 */
|
|
644
1029
|
function isSelectedValueVisible(text, field, option) {
|
|
645
1030
|
const normalizedText = normalizeVisibleText(text);
|