ai-dev-browser 0.5.3__py3-none-any.whl

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 (155) hide show
  1. ai_dev_browser/__init__.py +110 -0
  2. ai_dev_browser/_cli.py +428 -0
  3. ai_dev_browser/_version.py +13 -0
  4. ai_dev_browser/cdp/__init__.py +6 -0
  5. ai_dev_browser/cdp/accessibility.py +668 -0
  6. ai_dev_browser/cdp/animation.py +494 -0
  7. ai_dev_browser/cdp/audits.py +1990 -0
  8. ai_dev_browser/cdp/autofill.py +292 -0
  9. ai_dev_browser/cdp/background_service.py +215 -0
  10. ai_dev_browser/cdp/bluetooth_emulation.py +626 -0
  11. ai_dev_browser/cdp/browser.py +821 -0
  12. ai_dev_browser/cdp/cache_storage.py +311 -0
  13. ai_dev_browser/cdp/cast.py +172 -0
  14. ai_dev_browser/cdp/console.py +107 -0
  15. ai_dev_browser/cdp/crash_report_context.py +55 -0
  16. ai_dev_browser/cdp/css.py +2710 -0
  17. ai_dev_browser/cdp/debugger.py +1405 -0
  18. ai_dev_browser/cdp/device_access.py +141 -0
  19. ai_dev_browser/cdp/device_orientation.py +45 -0
  20. ai_dev_browser/cdp/dom.py +2257 -0
  21. ai_dev_browser/cdp/dom_debugger.py +321 -0
  22. ai_dev_browser/cdp/dom_snapshot.py +876 -0
  23. ai_dev_browser/cdp/dom_storage.py +222 -0
  24. ai_dev_browser/cdp/emulation.py +1779 -0
  25. ai_dev_browser/cdp/event_breakpoints.py +56 -0
  26. ai_dev_browser/cdp/extensions.py +246 -0
  27. ai_dev_browser/cdp/fed_cm.py +283 -0
  28. ai_dev_browser/cdp/fetch.py +507 -0
  29. ai_dev_browser/cdp/file_system.py +115 -0
  30. ai_dev_browser/cdp/headless_experimental.py +115 -0
  31. ai_dev_browser/cdp/heap_profiler.py +401 -0
  32. ai_dev_browser/cdp/indexed_db.py +528 -0
  33. ai_dev_browser/cdp/input_.py +701 -0
  34. ai_dev_browser/cdp/inspector.py +95 -0
  35. ai_dev_browser/cdp/io.py +101 -0
  36. ai_dev_browser/cdp/layer_tree.py +464 -0
  37. ai_dev_browser/cdp/log.py +190 -0
  38. ai_dev_browser/cdp/media.py +313 -0
  39. ai_dev_browser/cdp/memory.py +305 -0
  40. ai_dev_browser/cdp/network.py +5317 -0
  41. ai_dev_browser/cdp/overlay.py +1468 -0
  42. ai_dev_browser/cdp/page.py +3970 -0
  43. ai_dev_browser/cdp/performance.py +124 -0
  44. ai_dev_browser/cdp/performance_timeline.py +200 -0
  45. ai_dev_browser/cdp/preload.py +575 -0
  46. ai_dev_browser/cdp/profiler.py +420 -0
  47. ai_dev_browser/cdp/pwa.py +278 -0
  48. ai_dev_browser/cdp/py.typed +0 -0
  49. ai_dev_browser/cdp/runtime.py +1589 -0
  50. ai_dev_browser/cdp/schema.py +50 -0
  51. ai_dev_browser/cdp/security.py +518 -0
  52. ai_dev_browser/cdp/service_worker.py +401 -0
  53. ai_dev_browser/cdp/smart_card_emulation.py +891 -0
  54. ai_dev_browser/cdp/storage.py +1573 -0
  55. ai_dev_browser/cdp/system_info.py +327 -0
  56. ai_dev_browser/cdp/target.py +822 -0
  57. ai_dev_browser/cdp/tethering.py +65 -0
  58. ai_dev_browser/cdp/tracing.py +377 -0
  59. ai_dev_browser/cdp/util.py +17 -0
  60. ai_dev_browser/cdp/web_audio.py +606 -0
  61. ai_dev_browser/cdp/web_authn.py +598 -0
  62. ai_dev_browser/cdp/web_mcp.py +219 -0
  63. ai_dev_browser/core/__init__.py +218 -0
  64. ai_dev_browser/core/_case.py +38 -0
  65. ai_dev_browser/core/_cf_template.py +84 -0
  66. ai_dev_browser/core/_element.py +431 -0
  67. ai_dev_browser/core/_tab.py +817 -0
  68. ai_dev_browser/core/_transport.py +362 -0
  69. ai_dev_browser/core/ax.py +605 -0
  70. ai_dev_browser/core/browser.py +323 -0
  71. ai_dev_browser/core/cdp.py +66 -0
  72. ai_dev_browser/core/chrome.py +253 -0
  73. ai_dev_browser/core/cloudflare.py +77 -0
  74. ai_dev_browser/core/config.py +95 -0
  75. ai_dev_browser/core/connection.py +403 -0
  76. ai_dev_browser/core/cookies.py +103 -0
  77. ai_dev_browser/core/dialog.py +165 -0
  78. ai_dev_browser/core/download.py +38 -0
  79. ai_dev_browser/core/elements.py +913 -0
  80. ai_dev_browser/core/human.py +612 -0
  81. ai_dev_browser/core/login.py +122 -0
  82. ai_dev_browser/core/mouse.py +140 -0
  83. ai_dev_browser/core/navigation.py +225 -0
  84. ai_dev_browser/core/overlays.py +148 -0
  85. ai_dev_browser/core/page.py +302 -0
  86. ai_dev_browser/core/port.py +465 -0
  87. ai_dev_browser/core/process.py +114 -0
  88. ai_dev_browser/core/snapshot.py +425 -0
  89. ai_dev_browser/core/storage.py +50 -0
  90. ai_dev_browser/core/tabs.py +125 -0
  91. ai_dev_browser/core/text_match.py +217 -0
  92. ai_dev_browser/core/window.py +84 -0
  93. ai_dev_browser/pool/__init__.py +34 -0
  94. ai_dev_browser/pool/job.py +140 -0
  95. ai_dev_browser/pool/persistence.py +154 -0
  96. ai_dev_browser/pool/pool.py +873 -0
  97. ai_dev_browser/pool/worker.py +171 -0
  98. ai_dev_browser/profile.py +107 -0
  99. ai_dev_browser/py.typed +0 -0
  100. ai_dev_browser/tools/__init__.py +9 -0
  101. ai_dev_browser/tools/_generate.py +209 -0
  102. ai_dev_browser/tools/browser_list.py +14 -0
  103. ai_dev_browser/tools/browser_start.py +14 -0
  104. ai_dev_browser/tools/browser_stop.py +14 -0
  105. ai_dev_browser/tools/cdp_send.py +14 -0
  106. ai_dev_browser/tools/click_by_html_id.py +14 -0
  107. ai_dev_browser/tools/click_by_ref.py +14 -0
  108. ai_dev_browser/tools/click_by_text.py +14 -0
  109. ai_dev_browser/tools/click_by_xpath.py +14 -0
  110. ai_dev_browser/tools/cloudflare_verify.py +14 -0
  111. ai_dev_browser/tools/cookies_list.py +14 -0
  112. ai_dev_browser/tools/cookies_load.py +14 -0
  113. ai_dev_browser/tools/cookies_save.py +14 -0
  114. ai_dev_browser/tools/dialog_respond.py +14 -0
  115. ai_dev_browser/tools/download.py +14 -0
  116. ai_dev_browser/tools/drag_by_ref.py +14 -0
  117. ai_dev_browser/tools/find_by_html_id.py +14 -0
  118. ai_dev_browser/tools/find_by_xpath.py +14 -0
  119. ai_dev_browser/tools/focus_by_ref.py +14 -0
  120. ai_dev_browser/tools/highlight_by_ref.py +14 -0
  121. ai_dev_browser/tools/hover_by_ref.py +14 -0
  122. ai_dev_browser/tools/html_by_ref.py +14 -0
  123. ai_dev_browser/tools/js_evaluate.py +14 -0
  124. ai_dev_browser/tools/login_interactive.py +14 -0
  125. ai_dev_browser/tools/mouse_click.py +14 -0
  126. ai_dev_browser/tools/mouse_drag.py +14 -0
  127. ai_dev_browser/tools/mouse_move.py +14 -0
  128. ai_dev_browser/tools/page_discover.py +14 -0
  129. ai_dev_browser/tools/page_emulate_focus.py +14 -0
  130. ai_dev_browser/tools/page_goto.py +14 -0
  131. ai_dev_browser/tools/page_html.py +14 -0
  132. ai_dev_browser/tools/page_info.py +14 -0
  133. ai_dev_browser/tools/page_reload.py +14 -0
  134. ai_dev_browser/tools/page_screenshot.py +14 -0
  135. ai_dev_browser/tools/page_scroll.py +14 -0
  136. ai_dev_browser/tools/page_wait_element.py +14 -0
  137. ai_dev_browser/tools/page_wait_ready.py +14 -0
  138. ai_dev_browser/tools/page_wait_url.py +14 -0
  139. ai_dev_browser/tools/screenshot_by_ref.py +14 -0
  140. ai_dev_browser/tools/select_by_ref.py +14 -0
  141. ai_dev_browser/tools/storage_get.py +14 -0
  142. ai_dev_browser/tools/storage_set.py +14 -0
  143. ai_dev_browser/tools/tab_close.py +14 -0
  144. ai_dev_browser/tools/tab_list.py +14 -0
  145. ai_dev_browser/tools/tab_new.py +14 -0
  146. ai_dev_browser/tools/tab_switch.py +14 -0
  147. ai_dev_browser/tools/type_by_ref.py +14 -0
  148. ai_dev_browser/tools/type_by_text.py +14 -0
  149. ai_dev_browser/tools/upload_by_ref.py +14 -0
  150. ai_dev_browser/tools/window_set.py +14 -0
  151. ai_dev_browser-0.5.3.dist-info/METADATA +177 -0
  152. ai_dev_browser-0.5.3.dist-info/RECORD +155 -0
  153. ai_dev_browser-0.5.3.dist-info/WHEEL +5 -0
  154. ai_dev_browser-0.5.3.dist-info/licenses/LICENSE +25 -0
  155. ai_dev_browser-0.5.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,605 @@
1
+ """Accessibility tree operations for element interaction."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import re
6
+
7
+ from ai_dev_browser.cdp import dom
8
+ from ai_dev_browser.cdp import input_ as cdp_input
9
+ from ai_dev_browser.cdp import page
10
+
11
+ from ._tab import Tab
12
+
13
+ from .snapshot import _get_snapshot
14
+
15
+
16
+ def _parse_ref(ref: str) -> tuple[str | None, str, int | None]:
17
+ """Parse ref to extract frame prefix, local ref, and embedded node_id.
18
+
19
+ Ref format: "index#nodeId" or "FRAME_xxx:index#nodeId"
20
+ Examples:
21
+ "9#214" -> (None, "9", 214)
22
+ "FRAME_ABC123:9#214" -> ("FRAME_ABC123", "9", 214)
23
+ "9" -> (None, "9", None) # legacy format without node_id
24
+
25
+ Returns:
26
+ (frame_id_prefix, local_ref, node_id)
27
+ """
28
+ frame_prefix = None
29
+ local_ref = ref
30
+ node_id = None
31
+
32
+ # Check for frame prefix
33
+ frame_match = re.match(r"^(FRAME_[^:]+):(.+)$", ref)
34
+ if frame_match:
35
+ frame_prefix = frame_match.group(1)
36
+ local_ref = frame_match.group(2)
37
+
38
+ # Check for embedded node_id
39
+ node_match = re.match(r"^(\d+)#(\d+)$", local_ref)
40
+ if node_match:
41
+ local_ref = node_match.group(1)
42
+ node_id = int(node_match.group(2))
43
+
44
+ return frame_prefix, local_ref, node_id
45
+
46
+
47
+ async def _get_frame_id_by_prefix(tab: Tab, prefix: str) -> str | None:
48
+ """Find full frame ID by prefix (e.g., 'FRAME_ABC123' -> full frame ID)."""
49
+ try:
50
+ result = await tab.send(page.get_frame_tree())
51
+
52
+ def find_frame(frame_tree):
53
+ frame = frame_tree.frame
54
+ if f"FRAME_{frame.id_[:8]}" == prefix:
55
+ return frame.id_
56
+ if frame_tree.child_frames:
57
+ for child in frame_tree.child_frames:
58
+ found = find_frame(child)
59
+ if found:
60
+ return found
61
+ return None
62
+
63
+ return find_frame(result)
64
+ except Exception:
65
+ return None
66
+
67
+
68
+ async def _click_by_node_id(
69
+ tab: Tab,
70
+ node_id: int,
71
+ ) -> dict:
72
+ """Click element by backend node ID via CDP.
73
+
74
+ Args:
75
+ tab: Tab instance
76
+ node_id: Backend DOM node ID
77
+
78
+ Returns:
79
+ dict with clicked status
80
+ """
81
+ try:
82
+ # Wrap int in BackendNodeId
83
+ backend_node_id = dom.BackendNodeId(node_id)
84
+
85
+ # Get box model for the node
86
+ box = await tab.send(dom.get_box_model(backend_node_id=backend_node_id))
87
+ if not box or not box.content:
88
+ return {"clicked": False, "error": "Could not get element box model"}
89
+
90
+ # Get center of content box (content quad has 8 values: 4 x,y pairs)
91
+ quad = box.content
92
+ x = (quad[0] + quad[2] + quad[4] + quad[6]) / 4
93
+ y = (quad[1] + quad[3] + quad[5] + quad[7]) / 4
94
+
95
+ # Dispatch mouse events
96
+ await tab.send(
97
+ cdp_input.dispatch_mouse_event(
98
+ type_="mousePressed",
99
+ x=x,
100
+ y=y,
101
+ button=cdp_input.MouseButton.LEFT,
102
+ click_count=1,
103
+ )
104
+ )
105
+ await tab.send(
106
+ cdp_input.dispatch_mouse_event(
107
+ type_="mouseReleased",
108
+ x=x,
109
+ y=y,
110
+ button=cdp_input.MouseButton.LEFT,
111
+ click_count=1,
112
+ )
113
+ )
114
+ return {"clicked": True, "node_id": node_id}
115
+ except Exception as e:
116
+ return {"clicked": False, "error": str(e)}
117
+
118
+
119
+ async def _wait_for_ax_element(
120
+ tab: Tab,
121
+ wait_for_role: str | None = None,
122
+ wait_for_name: str | None = None,
123
+ timeout: float = 5.0,
124
+ interval: float = 0.3,
125
+ ) -> dict:
126
+ """Wait for an element to appear in the accessibility tree.
127
+
128
+ Args:
129
+ tab: Tab instance
130
+ wait_for_role: Role to wait for (e.g., "button", "menu")
131
+ wait_for_name: Name to wait for (substring match)
132
+ timeout: Max wait time in seconds
133
+ interval: Poll interval in seconds
134
+
135
+ Returns:
136
+ dict with found status and element info
137
+ """
138
+ if not wait_for_role and not wait_for_name:
139
+ return {"found": True, "skipped": True}
140
+
141
+ elapsed = 0.0
142
+ while elapsed < timeout:
143
+ await asyncio.sleep(interval)
144
+ elapsed += interval
145
+
146
+ try:
147
+ elements = await _get_snapshot(tab)
148
+ for el in elements:
149
+ role_match = wait_for_role is None or el.get("role") == wait_for_role
150
+ name_match = wait_for_name is None or wait_for_name in el.get(
151
+ "name", ""
152
+ )
153
+ if role_match and name_match:
154
+ return {
155
+ "found": True,
156
+ "element": {
157
+ "role": el.get("role"),
158
+ "name": el.get("name"),
159
+ "ref": el.get("ref"),
160
+ },
161
+ }
162
+ except Exception:
163
+ pass # Keep trying
164
+
165
+ return {"found": False, "timeout": True}
166
+
167
+
168
+ async def _click_ax_element(
169
+ tab: Tab,
170
+ ref: str | None = None,
171
+ node_id: int | None = None,
172
+ wait_for_role: str | None = None,
173
+ wait_for_name: str | None = None,
174
+ wait_timeout: float = 5.0,
175
+ wait_interval: float = 0.3,
176
+ ) -> dict:
177
+ """Click element by accessibility tree ref or node_id.
178
+
179
+ Use ax_tree (get_accessibility_tree) to get element refs, then this function
180
+ to click them. For stable clicks, pass node_id directly.
181
+
182
+ Supports iframe elements with prefixed refs like "FRAME_ABC123:5".
183
+
184
+ Args:
185
+ tab: Tab instance
186
+ ref: Element ref from ax_tree (e.g., "5" or "FRAME_ABC123:5" or "5#214")
187
+ node_id: Backend node ID - direct click, no re-fetch needed
188
+ wait_for_role: After click, wait for element with this role
189
+ wait_for_name: After click, wait for element with this name
190
+ wait_timeout: Max time to wait in seconds
191
+ wait_interval: Poll interval in seconds
192
+
193
+ Returns:
194
+ dict with clicked status, element info, and optional wait result
195
+ """
196
+ # Must specify at least one of ref or node_id
197
+ if ref is None and node_id is None:
198
+ return {"error": "Must specify ref or node_id"}
199
+
200
+ # If node_id provided directly, use it (stable, no re-fetch)
201
+ if node_id is not None:
202
+ result = await _click_by_node_id(tab, node_id)
203
+ if result.get("clicked") and (wait_for_role or wait_for_name):
204
+ waited = await _wait_for_ax_element(
205
+ tab, wait_for_role, wait_for_name, wait_timeout, wait_interval
206
+ )
207
+ if waited.get("found") and not waited.get("skipped"):
208
+ result["waited_for"] = waited.get("element")
209
+ elif waited.get("timeout"):
210
+ result["wait_timeout"] = True
211
+ return result
212
+
213
+ # Parse ref to extract frame prefix, local ref, and embedded node_id
214
+ frame_prefix, local_ref, embedded_node_id = _parse_ref(ref)
215
+
216
+ # If ref contains embedded node_id, use it directly (most reliable)
217
+ if embedded_node_id is not None:
218
+ result = await _click_by_node_id(tab, embedded_node_id)
219
+ if result.get("clicked"):
220
+ result["ref"] = ref
221
+ if wait_for_role or wait_for_name:
222
+ waited = await _wait_for_ax_element(
223
+ tab, wait_for_role, wait_for_name, wait_timeout, wait_interval
224
+ )
225
+ if waited.get("found") and not waited.get("skipped"):
226
+ result["waited_for"] = waited.get("element")
227
+ elif waited.get("timeout"):
228
+ result["wait_timeout"] = True
229
+ return result
230
+
231
+ # Fallback: re-fetch snapshot and find by ref (less reliable)
232
+ # Get frame ID if this is an iframe ref
233
+ frame_id = None
234
+ if frame_prefix:
235
+ frame_id = await _get_frame_id_by_prefix(tab, frame_prefix)
236
+ if not frame_id:
237
+ return {"error": f"Frame '{frame_prefix}' not found"}
238
+
239
+ # Get accessibility tree for the appropriate frame
240
+ elements = await _get_snapshot(tab, frame_id=frame_id)
241
+
242
+ # Find element by local ref (without frame prefix or node_id suffix)
243
+ target = None
244
+ for el in elements:
245
+ # Match by index part only (el.ref might be "9#214", we want to match "9")
246
+ el_ref = el.get("ref", "")
247
+ el_index = el_ref.split("#")[0] if "#" in el_ref else el_ref
248
+ if el_index == local_ref:
249
+ target = el
250
+ break
251
+
252
+ if not target:
253
+ return {"error": f"Element with ref '{ref}' not found"}
254
+
255
+ # Extract node_id from target's ref
256
+ target_ref = target.get("ref", "")
257
+ target_node_id = None
258
+ if "#" in target_ref:
259
+ with contextlib.suppress(ValueError):
260
+ target_node_id = int(target_ref.split("#")[1])
261
+
262
+ if not target_node_id:
263
+ return {"error": f"Element ref '{ref}' has no nodeId"}
264
+
265
+ # Click the element
266
+ result = await _click_by_node_id(tab, target_node_id)
267
+ if result.get("clicked"):
268
+ result["ref"] = ref
269
+ result["element"] = {
270
+ "role": target.get("role"),
271
+ "name": target.get("name"),
272
+ }
273
+ if wait_for_role or wait_for_name:
274
+ waited = await _wait_for_ax_element(
275
+ tab, wait_for_role, wait_for_name, wait_timeout, wait_interval
276
+ )
277
+ if waited.get("found") and not waited.get("skipped"):
278
+ result["waited_for"] = waited.get("element")
279
+ elif waited.get("timeout"):
280
+ result["wait_timeout"] = True
281
+
282
+ return result
283
+
284
+
285
+ async def click_by_ref(
286
+ tab: Tab,
287
+ ref: str,
288
+ ) -> dict:
289
+ """Click element by ref from page_discover().
290
+
291
+ This is the primary way for AI to click elements by ref. Use page_discover() first
292
+ to get element refs, then click_by_ref with the ref string.
293
+
294
+ Args:
295
+ tab: Tab instance
296
+ ref: Element ref from page_discover() (e.g., "5#214" or "FRAME_ABC123:5#214")
297
+
298
+ Returns:
299
+ dict with clicked status, element info, and navigation feedback:
300
+ `{clicked, ref, role, name, url_before, url_after, title_after, navigated}`.
301
+ `navigated=True` means the top-level URL changed after the click
302
+ (SPA route change or full page load). Use this to confirm the click
303
+ had the intended side effect instead of chaining a screenshot + discover.
304
+
305
+ Example:
306
+ # First page_discover elements
307
+ result = page_discover()
308
+ # Then click by ref
309
+ click_by_ref("5#214")
310
+ """
311
+ # Import lazily to avoid a circular dependency with elements.py, which
312
+ # imports from this module.
313
+ from .elements import _capture_page_state, _with_nav_feedback
314
+
315
+ url_before_state = await _capture_page_state(tab)
316
+ result = await _click_ax_element(tab, ref=ref)
317
+ result["url_before"] = url_before_state.get("url", "")
318
+ if not result.get("clicked"):
319
+ result.update(
320
+ {
321
+ "navigated": False,
322
+ "url_after": result["url_before"],
323
+ "title_after": "",
324
+ }
325
+ )
326
+ return result
327
+ return await _with_nav_feedback(tab, result)
328
+
329
+
330
+ async def focus_by_ref(
331
+ tab: Tab,
332
+ ref: str,
333
+ ) -> dict:
334
+ """Focus element by ref from page_discover().
335
+
336
+ Use page_discover() first to get element refs, then focus_by_ref.
337
+ Useful when you need to focus without clicking (e.g., to avoid
338
+ triggering click handlers).
339
+
340
+ Args:
341
+ tab: Tab instance
342
+ ref: Element ref from page_discover() (e.g., "5#214")
343
+
344
+ Returns:
345
+ dict with focused status
346
+
347
+ Example:
348
+ focus_by_ref("5#214")
349
+ """
350
+ # Parse ref to extract node_id
351
+ _, _, node_id = _parse_ref(ref)
352
+
353
+ if node_id is None:
354
+ return {"focused": False, "error": f"Invalid ref format: {ref}"}
355
+
356
+ try:
357
+ backend_node_id = dom.BackendNodeId(node_id)
358
+ await tab.send(dom.focus(backend_node_id=backend_node_id))
359
+ return {"focused": True, "ref": ref}
360
+ except Exception as e:
361
+ return {"focused": False, "error": str(e)}
362
+
363
+
364
+ async def type_by_ref(
365
+ tab: Tab,
366
+ ref: str,
367
+ text: str,
368
+ clear: bool = False,
369
+ ) -> dict:
370
+ """Type text into element by ref from page_discover().
371
+
372
+ Use page_discover() first to get element refs, then type_by_ref.
373
+
374
+ Args:
375
+ tab: Tab instance
376
+ ref: Element ref from page_discover() (e.g., "5#214")
377
+ text: Text to type into the element
378
+ clear: If True, clear existing content first
379
+
380
+ Returns:
381
+ dict with typed status
382
+
383
+ Example:
384
+ type_by_ref("5#214", "myusername")
385
+ type_by_ref("5#214", "newvalue", clear=True)
386
+ """
387
+ # First focus the element
388
+ focus_result = await focus_by_ref(tab, ref)
389
+ if not focus_result.get("focused"):
390
+ return {"typed": False, "error": focus_result.get("error", "Focus failed")}
391
+
392
+ # Clear if requested (select all + delete)
393
+ if clear:
394
+ # Select all
395
+ await tab.send(
396
+ cdp_input.dispatch_key_event(
397
+ type_="keyDown",
398
+ modifiers=2, # Ctrl/Cmd
399
+ key="a",
400
+ code="KeyA",
401
+ )
402
+ )
403
+ await tab.send(
404
+ cdp_input.dispatch_key_event(
405
+ type_="keyUp",
406
+ modifiers=2,
407
+ key="a",
408
+ code="KeyA",
409
+ )
410
+ )
411
+ # Delete
412
+ await tab.send(
413
+ cdp_input.dispatch_key_event(
414
+ type_="keyDown",
415
+ key="Backspace",
416
+ code="Backspace",
417
+ )
418
+ )
419
+ await tab.send(
420
+ cdp_input.dispatch_key_event(
421
+ type_="keyUp",
422
+ key="Backspace",
423
+ code="Backspace",
424
+ )
425
+ )
426
+
427
+ # Type text using insertText (most reliable for input fields)
428
+ await tab.send(cdp_input.insert_text(text=text))
429
+
430
+ return {"typed": True, "ref": ref, "text": text}
431
+
432
+
433
+ # ---------------------------------------------------------------------------
434
+ # Element tools (by ref) — all use get_element_by_ref helper
435
+ # ---------------------------------------------------------------------------
436
+
437
+
438
+ async def hover_by_ref(
439
+ tab: Tab,
440
+ ref: str,
441
+ ) -> dict:
442
+ """Move mouse to element (hover) by ref from page_discover().
443
+
444
+ Useful for triggering hover menus, tooltips, or dropdown previews.
445
+
446
+ Args:
447
+ tab: Tab instance
448
+ ref: Element ref from page_discover()
449
+
450
+ Returns:
451
+ dict with hovered status
452
+ """
453
+ from ._element import get_element_by_ref
454
+
455
+ element = await get_element_by_ref(tab, ref)
456
+ await element.mouse_move()
457
+ return {"hovered": True, "ref": ref}
458
+
459
+
460
+ async def highlight_by_ref(
461
+ tab: Tab,
462
+ ref: str,
463
+ duration: float = 2.0,
464
+ ) -> dict:
465
+ """Highlight element with colored overlay by ref from page_discover().
466
+
467
+ Useful for visual debugging — confirms which element was found.
468
+
469
+ Args:
470
+ tab: Tab instance
471
+ ref: Element ref from page_discover()
472
+ duration: How long to show highlight in seconds
473
+
474
+ Returns:
475
+ dict with highlighted status
476
+ """
477
+ from ._element import get_element_by_ref
478
+
479
+ element = await get_element_by_ref(tab, ref)
480
+ await element.highlight_overlay(duration=duration)
481
+ return {"highlighted": True, "ref": ref}
482
+
483
+
484
+ async def html_by_ref(
485
+ tab: Tab,
486
+ ref: str,
487
+ ) -> dict:
488
+ """Get outerHTML of element by ref from page_discover().
489
+
490
+ Args:
491
+ tab: Tab instance
492
+ ref: Element ref from page_discover()
493
+
494
+ Returns:
495
+ dict with html content
496
+ """
497
+ from ._element import get_element_by_ref
498
+
499
+ element = await get_element_by_ref(tab, ref)
500
+ html = await element.get_html()
501
+ return {"html": html, "ref": ref}
502
+
503
+
504
+ async def screenshot_by_ref(
505
+ tab: Tab,
506
+ ref: str,
507
+ path: str | None = None,
508
+ ) -> dict:
509
+ """Take screenshot of just this element's region by ref from page_discover().
510
+
511
+ Args:
512
+ tab: Tab instance
513
+ ref: Element ref from page_discover()
514
+ path: File path to save (default: screenshots/{timestamp}_element.png)
515
+
516
+ Returns:
517
+ dict with path
518
+ """
519
+ import datetime
520
+ from pathlib import Path
521
+
522
+ from ._element import get_element_by_ref
523
+ from .config import DEFAULT_SCREENSHOT_DIR
524
+
525
+ if path is None:
526
+ DEFAULT_SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
527
+ ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
528
+ path = str(DEFAULT_SCREENSHOT_DIR / f"{ts}_element.png")
529
+
530
+ element = await get_element_by_ref(tab, ref)
531
+ saved = await element.save_screenshot(path)
532
+ file_size = Path(saved).stat().st_size
533
+ return {"path": saved, "size": file_size, "ref": ref}
534
+
535
+
536
+ async def select_by_ref(
537
+ tab: Tab,
538
+ ref: str,
539
+ ) -> dict:
540
+ """Select a dropdown option by ref from page_discover().
541
+
542
+ For <select> elements: use page_discover() to see options, then select_by_ref
543
+ on the <option> element.
544
+
545
+ Args:
546
+ tab: Tab instance
547
+ ref: Element ref of the <option> to select
548
+
549
+ Returns:
550
+ dict with selected status
551
+ """
552
+ from ._element import get_element_by_ref
553
+
554
+ element = await get_element_by_ref(tab, ref)
555
+ await element.select_option()
556
+ return {"selected": True, "ref": ref}
557
+
558
+
559
+ async def upload_by_ref(
560
+ tab: Tab,
561
+ ref: str,
562
+ paths: str,
563
+ ) -> dict:
564
+ """Upload file(s) to a file input by ref from page_discover().
565
+
566
+ Args:
567
+ tab: Tab instance
568
+ ref: Element ref of the <input type="file">
569
+ paths: Comma-separated file paths to upload
570
+
571
+ Returns:
572
+ dict with uploaded status and file count
573
+ """
574
+ from ._element import get_element_by_ref
575
+
576
+ element = await get_element_by_ref(tab, ref)
577
+ file_list = [p.strip() for p in paths.split(",")]
578
+ await element.send_file(*file_list)
579
+ return {"uploaded": True, "ref": ref, "files": len(file_list)}
580
+
581
+
582
+ async def drag_by_ref(
583
+ tab: Tab,
584
+ ref: str,
585
+ to_x: float,
586
+ to_y: float,
587
+ steps: int = 10,
588
+ ) -> dict:
589
+ """Drag element to destination coordinates by ref from page_discover().
590
+
591
+ Args:
592
+ tab: Tab instance
593
+ ref: Element ref to drag from
594
+ to_x: Destination X coordinate
595
+ to_y: Destination Y coordinate
596
+ steps: Number of intermediate steps
597
+
598
+ Returns:
599
+ dict with dragged status
600
+ """
601
+ from ._element import get_element_by_ref
602
+
603
+ element = await get_element_by_ref(tab, ref)
604
+ await element.mouse_drag(to_x, to_y, steps=steps)
605
+ return {"dragged": True, "ref": ref}