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.
Files changed (57) hide show
  1. package/dist/browser-core/agent.d.ts +4 -0
  2. package/dist/browser-core/agent.d.ts.map +1 -1
  3. package/dist/browser-core/agent.js +13 -0
  4. package/dist/browser-core/agent.js.map +1 -1
  5. package/dist/browser-opt/index.d.ts +1 -1
  6. package/dist/browser-opt/index.d.ts.map +1 -1
  7. package/dist/browser-opt/runner/actions/index.d.ts +1 -1
  8. package/dist/browser-opt/runner/actions/index.d.ts.map +1 -1
  9. package/dist/browser-opt/runner/actions/index.js +135 -3
  10. package/dist/browser-opt/runner/actions/index.js.map +1 -1
  11. package/dist/browser-opt/runner/actions/utils/click-action.d.ts +1 -1
  12. package/dist/browser-opt/runner/actions/utils/click-action.d.ts.map +1 -1
  13. package/dist/browser-opt/runner/actions/utils/click-action.js +10 -4
  14. package/dist/browser-opt/runner/actions/utils/click-action.js.map +1 -1
  15. package/dist/browser-opt/runner/actions/utils/date-action.d.ts +1 -1
  16. package/dist/browser-opt/runner/actions/utils/date-action.d.ts.map +1 -1
  17. package/dist/browser-opt/runner/actions/utils/date-action.js +5 -1
  18. package/dist/browser-opt/runner/actions/utils/date-action.js.map +1 -1
  19. package/dist/browser-opt/runner/actions/utils/dom-action.d.ts +14 -3
  20. package/dist/browser-opt/runner/actions/utils/dom-action.d.ts.map +1 -1
  21. package/dist/browser-opt/runner/actions/utils/dom-action.js +301 -20
  22. package/dist/browser-opt/runner/actions/utils/dom-action.js.map +1 -1
  23. package/dist/browser-opt/runner/actions/utils/fill-action.d.ts +1 -1
  24. package/dist/browser-opt/runner/actions/utils/fill-action.d.ts.map +1 -1
  25. package/dist/browser-opt/runner/actions/utils/fill-action.js +27 -7
  26. package/dist/browser-opt/runner/actions/utils/fill-action.js.map +1 -1
  27. package/dist/browser-opt/runner/actions/utils/select-option-action.d.ts +1 -1
  28. package/dist/browser-opt/runner/actions/utils/select-option-action.d.ts.map +1 -1
  29. package/dist/browser-opt/runner/actions/utils/select-option-action.js +467 -82
  30. package/dist/browser-opt/runner/actions/utils/select-option-action.js.map +1 -1
  31. package/dist/browser-opt/runner/actions/utils/table-row-checkbox-action.d.ts +2 -2
  32. package/dist/browser-opt/runner/actions/utils/table-row-checkbox-action.d.ts.map +1 -1
  33. package/dist/browser-opt/runner/actions/utils/table-row-checkbox-action.js +79 -19
  34. package/dist/browser-opt/runner/actions/utils/table-row-checkbox-action.js.map +1 -1
  35. package/dist/browser-opt/runner/actions/utils/upload-action.d.ts +8 -1
  36. package/dist/browser-opt/runner/actions/utils/upload-action.d.ts.map +1 -1
  37. package/dist/browser-opt/runner/actions/utils/upload-action.js +517 -64
  38. package/dist/browser-opt/runner/actions/utils/upload-action.js.map +1 -1
  39. package/dist/browser-opt/runner/evidence.d.ts.map +1 -1
  40. package/dist/browser-opt/runner/evidence.js +2 -1
  41. package/dist/browser-opt/runner/evidence.js.map +1 -1
  42. package/dist/browser-opt/runner/index.d.ts.map +1 -1
  43. package/dist/browser-opt/runner/index.js +7 -0
  44. package/dist/browser-opt/runner/index.js.map +1 -1
  45. package/dist/browser-opt/runner/step-executor.d.ts.map +1 -1
  46. package/dist/browser-opt/runner/step-executor.js +39 -8
  47. package/dist/browser-opt/runner/step-executor.js.map +1 -1
  48. package/dist/browser-opt/type.d.ts +29 -0
  49. package/dist/browser-opt/type.d.ts.map +1 -1
  50. package/dist/browser-opt/utils.d.ts.map +1 -1
  51. package/dist/browser-opt/utils.js +204 -19
  52. package/dist/browser-opt/utils.js.map +1 -1
  53. package/dist/cli/utils/output.d.ts.map +1 -1
  54. package/dist/cli/utils/output.js +11 -0
  55. package/dist/cli/utils/output.js.map +1 -1
  56. package/package.json +1 -1
  57. 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
- /** 执行选项选择动作,包含 switch、原生单选复选框和长表单滚动搜索兜底。 */
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 switchDomTarget = action.field && (isSwitchSelectable(option.role) || snapshotHasSwitchField(snapshot, action.field))
24
- ? clickSwitchDomTarget(agent, action.field, action.option)
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 nativeSelectableDomTarget = clickNativeSelectableDomTarget(agent, option.role, action.option);
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
- if ((isSwitchSelectable(selected.role) || snapshotHasSwitchField(afterSnapshot, action.field)) && action.field) {
84
- const domState = verifySwitchDomState(agent, action.field, action.option);
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: `已确认开关状态:${action.field}=${action.option}` };
128
+ return { passed: true, message: `已确认开关状态:${switchField}=${action.option}` };
87
129
  }
88
130
  if (domState === false) {
89
- return { passed: false, message: `DOM 确认开关未达到目标状态:${action.field}=${action.option}` };
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
- /** radio/checkbox 的无障碍 ref 可能指向隐藏 input,文案唯一时改点真实 label 以触发组件事件。 */
105
- function clickNativeSelectableDomTarget(agent, role, option) {
106
- if (!/radio|checkbox/i.test(role ?? '')) {
107
- return null;
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 labels = [...document.querySelectorAll('label')].filter((label) => normalize(label.textContent) === target);
113
- if (labels.length !== 1) {
114
- return JSON.stringify({ matchedCount: labels.length, clicked: false });
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
- const input = labels[0].querySelector('input[type="radio"], input[type="checkbox"]');
207
+
208
+ const { label, input } = scoped[0];
117
209
  if (!input || input.disabled) {
118
- return JSON.stringify({ matchedCount: 1, clicked: false, disabled: Boolean(input?.disabled) });
210
+ return JSON.stringify({ matchedCount: scoped.length, clicked: false, disabled: Boolean(input?.disabled) });
119
211
  }
120
- labels[0].click();
121
- return JSON.stringify({ matchedCount: 1, clicked: true, checked: input.checked });
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
- return parsed.matchedCount === 1 && parsed.clicked === true
126
- ? `selectable dom click ${option}`
127
- : null;
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
- /** 普通下拉优先按 DOM 中字段同组控件和可见弹层选项点击,避开嵌套布局里的 ref 坐标漂移。 */
134
- function clickDropdownDomTarget(agent, field, option) {
135
- const openScript = `(() => {
136
- const selectHelper = ${dropdownDomHelperSource()};
137
- return JSON.stringify(selectHelper.openDropdownByField(${JSON.stringify(field)}));
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
- const opened = parseEvalJson(agent.evaluate(openScript));
141
- if (typeof opened.opened !== 'boolean') {
142
- return { attempted: false, output: null };
143
- }
144
- if (!opened.found) {
145
- return { attempted: false, output: null };
146
- }
147
- if (opened.opened === false) {
148
- return { attempted: true, output: null };
149
- }
150
- agent.waitMs(300);
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
- const retryClickScript = `(() => {
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.clickVisibleOption(${JSON.stringify(option)}));
398
+ return JSON.stringify(selectHelper.scrollActiveDropdown(${JSON.stringify(option)}));
167
399
  })()`;
168
- Object.assign(clicked, parseEvalJson(agent.evaluate(retryClickScript)));
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 === true) {
172
- agent.waitMs(300);
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
- return { attempted: true, output: null };
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
- if (!result.found || !result.switchId || typeof result.desiredChecked !== 'boolean') {
245
- return JSON.stringify(result);
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({ ...result, clicked: false });
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({ ...result, clicked: true });
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
- return JSON.stringify(result);
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) => (a.rect.width * a.rect.height) - (b.rect.width * b.rect.height))[0];
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
- return { openDropdownByField, clickVisibleOption, searchActiveDropdown, dismissActiveDropdown, hasVisibleDropdown };
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) && normalizeBrowserOptText(element.textContent).includes(fieldText))
563
- .map((element) => ({ element, rect: element.getBoundingClientRect() }))
564
- .sort((a, b) => (a.rect.width * a.rect.height) - (b.rect.width * b.rect.height));
565
- const switches = browserOptSwitches().map((element, index) => {
566
- const id = 'browser-opt-switch-' + index;
567
- element.setAttribute('data-browser-opt-switch-id', id);
568
- return {
569
- element,
570
- id,
571
- rect: element.getBoundingClientRect(),
572
- checked: browserOptSwitchState(element),
573
- desiredChecked: browserOptSwitchDesiredState(element, option),
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, switchId: target.id };
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);