X5Browser 1.1.0__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.
- x5browser/__init__.py +45 -0
- x5browser/browser/__init__.py +5 -0
- x5browser/browser/actions.py +535 -0
- x5browser/browser/api.py +4304 -0
- x5browser/browser/console.py +147 -0
- x5browser/browser/dialogs.py +73 -0
- x5browser/browser/downloads.py +93 -0
- x5browser/browser/format.py +92 -0
- x5browser/browser/network.py +197 -0
- x5browser/browser/observe.py +777 -0
- x5browser/browser/permissions.py +100 -0
- x5browser/browser/tabs.py +83 -0
- x5browser/browser/tools.py +665 -0
- x5browser/browser/types.py +57 -0
- x5browser/browser/waits.py +90 -0
- x5browser/cdp/__init__.py +1 -0
- x5browser/cdp/client.py +235 -0
- x5browser/cdp/domains.py +96 -0
- x5browser/chrome/__init__.py +1 -0
- x5browser/chrome/launcher.py +240 -0
- x5browser/chrome/profile.py +83 -0
- x5browser/config.py +95 -0
- x5browser/control/__init__.py +1 -0
- x5browser/control/state.py +132 -0
- x5browser/defense/__init__.py +13 -0
- x5browser/defense/allowlist.py +155 -0
- x5browser/defense/allowlist.yaml +69 -0
- x5browser/defense/blocklists.py +88 -0
- x5browser/defense/flags.py +59 -0
- x5browser/defense/heuristics.py +139 -0
- x5browser/defense/lists/custom.txt +11 -0
- x5browser/defense/manager.py +271 -0
- x5browser/defense/network_blocker.py +102 -0
- x5browser/defense/policy.json +29 -0
- x5browser/defense/profile.py +89 -0
- x5browser/defense/selfheal.py +67 -0
- x5browser/defense/shadow.py +68 -0
- x5browser/defense/site_overrides.yaml +13 -0
- x5browser/defense/tab_guard.py +161 -0
- x5browser/defense/tree_filter.py +43 -0
- x5browser/input/__init__.py +1 -0
- x5browser/input/keymap.py +67 -0
- x5browser/live/__init__.py +11 -0
- x5browser/live/gateway.py +389 -0
- x5browser/live/links.py +148 -0
- x5browser/live/stack.py +338 -0
- x5browser/live/units/x5-cdp-relay.service +13 -0
- x5browser/live/units/x5-chrome.service +18 -0
- x5browser/live/units/x5-clipboard.service +15 -0
- x5browser/live/units/x5-novnc.service +13 -0
- x5browser/live/units/x5-vnc.service +15 -0
- x5browser/live/units/x5-xvfb.service +12 -0
- x5browser/live/web/test.html +209 -0
- x5browser/live/web/x5-live.html +193 -0
- x5browser/live_browser.py +252 -0
- x5browser/logging/__init__.py +1 -0
- x5browser/logging/action_log.py +80 -0
- x5browser/maintenance.py +165 -0
- x5browser/platform.py +66 -0
- x5browser/security/__init__.py +1 -0
- x5browser/security/policy.py +20 -0
- x5browser/task_session.py +238 -0
- x5browser-1.1.0.dist-info/METADATA +218 -0
- x5browser-1.1.0.dist-info/RECORD +67 -0
- x5browser-1.1.0.dist-info/WHEEL +5 -0
- x5browser-1.1.0.dist-info/licenses/LICENSE +21 -0
- x5browser-1.1.0.dist-info/top_level.txt +1 -0
x5browser/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""X5Browser: single-folder browser library for AI agents.
|
|
2
|
+
|
|
3
|
+
Local package usage (no Docker)::
|
|
4
|
+
|
|
5
|
+
from x5browser import LiveBrowser
|
|
6
|
+
|
|
7
|
+
live = LiveBrowser() # hidden Chrome + live link + timeline
|
|
8
|
+
await live.start() # stack + agent + self-cleaning janitor
|
|
9
|
+
print(live.live_url) # open / embed in any <iframe>
|
|
10
|
+
live.get_tools() # catalog (full=True for all tools)
|
|
11
|
+
await live.navigate("https://example.com") # screenshot per action
|
|
12
|
+
await live.take_control() # user drives the same browser
|
|
13
|
+
await live.agent_control() # agent resumes with fresh observe
|
|
14
|
+
await live.stop_browser() # quiet close, data kept, reopen on demand
|
|
15
|
+
await live.stop()
|
|
16
|
+
|
|
17
|
+
Lower layers stay importable directly (X5Browser + tool catalog,
|
|
18
|
+
ControlManager, LiveStack, Janitor) for projects that wire their own UI.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .browser.api import X5Browser
|
|
22
|
+
from .browser.tools import TOOL_SCHEMAS
|
|
23
|
+
from .browser.types import ActionResult
|
|
24
|
+
from .config import PROJECT_ROOT, BrowserConfig
|
|
25
|
+
from .control.state import ControlManager
|
|
26
|
+
from .live.stack import LiveStack
|
|
27
|
+
from .live_browser import LiveBrowser
|
|
28
|
+
from .maintenance import Janitor
|
|
29
|
+
from .platform import default_profile_dir, detect_host
|
|
30
|
+
|
|
31
|
+
__version__ = "1.1.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"LiveBrowser",
|
|
35
|
+
"X5Browser",
|
|
36
|
+
"ActionResult",
|
|
37
|
+
"BrowserConfig",
|
|
38
|
+
"ControlManager",
|
|
39
|
+
"Janitor",
|
|
40
|
+
"LiveStack",
|
|
41
|
+
"TOOL_SCHEMAS",
|
|
42
|
+
"PROJECT_ROOT",
|
|
43
|
+
"default_profile_dir",
|
|
44
|
+
"detect_host",
|
|
45
|
+
]
|
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Real-input page actions (M1, spec 9).
|
|
2
|
+
|
|
3
|
+
Every function sends genuine CDP Input.* events only. No JavaScript is ever
|
|
4
|
+
injected for interaction (spec non-negotiable). Each action resolves the ref
|
|
5
|
+
against the current observation, verifies, acts, then waits stable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .types import AgentSession, ElementRef
|
|
13
|
+
from ..input.keymap import (
|
|
14
|
+
MOD_ALT,
|
|
15
|
+
MOD_CTRL,
|
|
16
|
+
MOD_META,
|
|
17
|
+
MOD_SHIFT,
|
|
18
|
+
MODIFIERS,
|
|
19
|
+
NAMED_KEYS,
|
|
20
|
+
parse_key,
|
|
21
|
+
parse_modifiers,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_ref(session: AgentSession, ref: int) -> ElementRef:
|
|
26
|
+
"""Fetch a ref from the current observation or raise a fix-hinted error."""
|
|
27
|
+
observation = session.observation
|
|
28
|
+
if observation is None or ref not in observation.refs:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
f"error: ref_not_found - ref {ref} not found (page changed). "
|
|
31
|
+
"call page_snapshot()."
|
|
32
|
+
)
|
|
33
|
+
return observation.refs[ref]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def element_session(session: AgentSession, element: ElementRef) -> str:
|
|
37
|
+
"""Owning CDP session of an element (frame sessions fall back to main)."""
|
|
38
|
+
return element.session_id or session.session_id
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def quad_center(quad: list | None) -> tuple[float, float] | None:
|
|
42
|
+
"""Center of a content quad, or None when geometry is unknown."""
|
|
43
|
+
if not quad or len(quad) < 8:
|
|
44
|
+
return None
|
|
45
|
+
xs = quad[0::2]
|
|
46
|
+
ys = quad[1::2]
|
|
47
|
+
return (sum(xs) / len(xs), sum(ys) / len(ys))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _mouse(
|
|
51
|
+
client: Any, session_id: str, action: str, x: float, y: float, **extra: Any
|
|
52
|
+
) -> None:
|
|
53
|
+
params: dict[str, Any] = {"type": action, "x": x, "y": y, **extra}
|
|
54
|
+
await client.send("Input.dispatchMouseEvent", params, session_id=session_id)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _key(
|
|
58
|
+
client: Any,
|
|
59
|
+
session_id: str,
|
|
60
|
+
action: str,
|
|
61
|
+
key: str,
|
|
62
|
+
code: str,
|
|
63
|
+
windows_code: int = 0,
|
|
64
|
+
modifiers: int = 0,
|
|
65
|
+
text: str | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
params: dict[str, Any] = {
|
|
68
|
+
"type": action,
|
|
69
|
+
"key": key,
|
|
70
|
+
"code": code,
|
|
71
|
+
"windowsVirtualKeyCode": windows_code,
|
|
72
|
+
"modifiers": modifiers,
|
|
73
|
+
}
|
|
74
|
+
if text is not None:
|
|
75
|
+
params["text"] = text
|
|
76
|
+
await client.send("Input.dispatchKeyEvent", params, session_id=session_id)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def settle_scroll(client: Any, session_id: str) -> None:
|
|
80
|
+
"""Wait until the scroll position stops changing (max ~1s).
|
|
81
|
+
|
|
82
|
+
scrollIntoView settles asynchronously against hit-testing: pressing on
|
|
83
|
+
quads read mid-settle can hit the wrong element (verified), so every
|
|
84
|
+
targeting path settles first.
|
|
85
|
+
"""
|
|
86
|
+
last: tuple = ()
|
|
87
|
+
for _ in range(10):
|
|
88
|
+
try:
|
|
89
|
+
metrics = await client.send(
|
|
90
|
+
"Page.getLayoutMetrics", session_id=session_id
|
|
91
|
+
)
|
|
92
|
+
page = metrics.get("layoutViewport", {}) or {}
|
|
93
|
+
position = (page.get("pageX", 0), page.get("pageY", 0))
|
|
94
|
+
except Exception: # noqa: BLE001 - a lost metrics call ends settling
|
|
95
|
+
return
|
|
96
|
+
if position == last:
|
|
97
|
+
return
|
|
98
|
+
last = position
|
|
99
|
+
import asyncio
|
|
100
|
+
|
|
101
|
+
await asyncio.sleep(0.1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def element_center(
|
|
105
|
+
session: AgentSession, element: ElementRef
|
|
106
|
+
) -> tuple[float, float]:
|
|
107
|
+
"""Scroll into view, settle, and return the fresh page-space center."""
|
|
108
|
+
target = element_session(session, element)
|
|
109
|
+
center: tuple[float, float] | None = None
|
|
110
|
+
for _ in range(3): # bounded retries (Absolute Rule 20)
|
|
111
|
+
await session.client.send(
|
|
112
|
+
"DOM.scrollIntoViewIfNeeded",
|
|
113
|
+
{"backendNodeId": element.backend_id},
|
|
114
|
+
session_id=target,
|
|
115
|
+
)
|
|
116
|
+
await settle_scroll(session.client, target)
|
|
117
|
+
try:
|
|
118
|
+
result = await session.client.send(
|
|
119
|
+
"DOM.getContentQuads",
|
|
120
|
+
{"backendNodeId": element.backend_id},
|
|
121
|
+
session_id=target,
|
|
122
|
+
)
|
|
123
|
+
quads = result.get("quads", [])
|
|
124
|
+
center = quad_center(list(quads[0])) if quads else None
|
|
125
|
+
except Exception: # noqa: BLE001 - fall back to the observe-time quad
|
|
126
|
+
center = None
|
|
127
|
+
center = center or quad_center(element.quad)
|
|
128
|
+
if center is None:
|
|
129
|
+
break
|
|
130
|
+
owner = await coverage_owner(
|
|
131
|
+
session, center[0] + element.dx, center[1] + element.dy
|
|
132
|
+
)
|
|
133
|
+
if owner is None or await is_same_or_descendant(
|
|
134
|
+
session, element.backend_id, owner
|
|
135
|
+
) or await is_frame_hit(session, element, owner):
|
|
136
|
+
break
|
|
137
|
+
if center is None:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"error: element_not_visible - {element.role} {element.name!r} "
|
|
140
|
+
"has no geometry. call page_snapshot()."
|
|
141
|
+
)
|
|
142
|
+
return (center[0] + element.dx, center[1] + element.dy)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def is_same_or_descendant(
|
|
146
|
+
session: AgentSession, ancestor_backend: int, node_backend: int
|
|
147
|
+
) -> bool:
|
|
148
|
+
"""True when the hit-tested node is the target or inside it.
|
|
149
|
+
|
|
150
|
+
Hit-testing may return an inner text node instead of the element itself;
|
|
151
|
+
that is a hit, not a cover. Bounded subtree search (depth 5).
|
|
152
|
+
"""
|
|
153
|
+
if ancestor_backend == node_backend:
|
|
154
|
+
return True
|
|
155
|
+
return await _backend_contains(
|
|
156
|
+
session, ancestor_backend, node_backend, session.session_id
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def is_frame_hit(
|
|
161
|
+
session: AgentSession, element: ElementRef, node_backend: int
|
|
162
|
+
) -> bool:
|
|
163
|
+
"""True when the hit node is the frame element wrapping a frame target.
|
|
164
|
+
|
|
165
|
+
Main-target hit-testing stops at the iframe element (an ancestor of the
|
|
166
|
+
inner button), which can never occlude its own content. Accepted only
|
|
167
|
+
for frame elements and only when the owner is a genuine ancestor.
|
|
168
|
+
"""
|
|
169
|
+
if not element.session_id and not element.dx and not element.dy:
|
|
170
|
+
return False
|
|
171
|
+
try:
|
|
172
|
+
described = await session.client.send(
|
|
173
|
+
"DOM.describeNode",
|
|
174
|
+
{"backendNodeId": node_backend, "depth": 5},
|
|
175
|
+
session_id=session.session_id,
|
|
176
|
+
)
|
|
177
|
+
except Exception: # noqa: BLE001 - unknown tree means not verifiable
|
|
178
|
+
return False
|
|
179
|
+
node = described.get("node") or {}
|
|
180
|
+
if (node.get("nodeName", "") or "").upper() not in ("IFRAME", "OBJECT", "EMBED"):
|
|
181
|
+
return False
|
|
182
|
+
return await _backend_contains(
|
|
183
|
+
session, node_backend, element.backend_id, session.session_id
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def _backend_contains(
|
|
188
|
+
session: AgentSession, ancestor_backend: int, node_backend: int,
|
|
189
|
+
target_session: str,
|
|
190
|
+
) -> bool:
|
|
191
|
+
"""Bounded subtree search for one backend id under another."""
|
|
192
|
+
for pierce in (True, False):
|
|
193
|
+
try:
|
|
194
|
+
described = await session.client.send(
|
|
195
|
+
"DOM.describeNode",
|
|
196
|
+
{"backendNodeId": ancestor_backend, "depth": 5,
|
|
197
|
+
"pierce": pierce},
|
|
198
|
+
session_id=target_session,
|
|
199
|
+
)
|
|
200
|
+
except Exception: # noqa: BLE001 - unknown tree means not verifiable
|
|
201
|
+
continue
|
|
202
|
+
if _subtree_has(described.get("node") or {}, node_backend):
|
|
203
|
+
return True
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _subtree_has(node: dict, node_backend: int) -> bool:
|
|
208
|
+
"""Iterative search through children, content documents, and shadow roots."""
|
|
209
|
+
stack = [node]
|
|
210
|
+
while stack:
|
|
211
|
+
current = stack.pop()
|
|
212
|
+
if current.get("backendNodeId") == node_backend:
|
|
213
|
+
return True
|
|
214
|
+
stack.extend(
|
|
215
|
+
child for child in current.get("children", []) or []
|
|
216
|
+
if isinstance(child, dict)
|
|
217
|
+
)
|
|
218
|
+
content = current.get("contentDocument")
|
|
219
|
+
if isinstance(content, dict):
|
|
220
|
+
stack.extend(
|
|
221
|
+
child for child in content.get("children", []) or []
|
|
222
|
+
if isinstance(child, dict)
|
|
223
|
+
)
|
|
224
|
+
for shadow in current.get("shadowRoots", []) or []:
|
|
225
|
+
if isinstance(shadow, dict):
|
|
226
|
+
stack.extend(
|
|
227
|
+
child for child in shadow.get("children", []) or []
|
|
228
|
+
if isinstance(child, dict)
|
|
229
|
+
)
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def coverage_owner(
|
|
234
|
+
session: AgentSession, x: float, y: float
|
|
235
|
+
) -> int | None:
|
|
236
|
+
"""Backend id of the top node at viewport point (x, y), None when unknown.
|
|
237
|
+
|
|
238
|
+
getNodeForLocation expects DOCUMENT coordinates (verified: viewport
|
|
239
|
+
coords fail once scrolled), so the current scroll offset is added.
|
|
240
|
+
"""
|
|
241
|
+
client = session.client
|
|
242
|
+
try:
|
|
243
|
+
metrics = await client.send(
|
|
244
|
+
"Page.getLayoutMetrics", session_id=session.session_id
|
|
245
|
+
)
|
|
246
|
+
page = metrics.get("layoutViewport", {}) or {}
|
|
247
|
+
doc_x = x + float(page.get("pageX", 0))
|
|
248
|
+
doc_y = y + float(page.get("pageY", 0))
|
|
249
|
+
result = await client.send(
|
|
250
|
+
"DOM.getNodeForLocation", {"x": int(doc_x), "y": int(doc_y)},
|
|
251
|
+
session_id=session.session_id,
|
|
252
|
+
)
|
|
253
|
+
backend_id = result.get("backendNodeId")
|
|
254
|
+
return backend_id if isinstance(backend_id, int) else None
|
|
255
|
+
except Exception: # noqa: BLE001 - coverage check is best-effort
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def click_at(client: Any, session_id: str, x: float, y: float) -> None:
|
|
260
|
+
"""Raw viewport click: move, press, release (left button)."""
|
|
261
|
+
await _mouse(client, session_id, "mouseMoved", x, y)
|
|
262
|
+
await _mouse(client, session_id, "mouseMoved", x, y)
|
|
263
|
+
await _mouse(
|
|
264
|
+
client, session_id, "mousePressed", x, y, button="left", clickCount=1
|
|
265
|
+
)
|
|
266
|
+
await _mouse(
|
|
267
|
+
client, session_id, "mouseReleased", x, y, button="left", clickCount=1
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def element_href(session: AgentSession, element: ElementRef) -> str:
|
|
272
|
+
"""Raw href of one element (CDP read only, never JS). Empty when none.
|
|
273
|
+
|
|
274
|
+
Reads the node's own attributes via DOM.describeNode; ancestors are
|
|
275
|
+
not walked (documented limit: wrapped inner spans fall back to clicks).
|
|
276
|
+
"""
|
|
277
|
+
try:
|
|
278
|
+
described = await session.client.send(
|
|
279
|
+
"DOM.describeNode",
|
|
280
|
+
{"backendNodeId": element.backend_id, "depth": 0},
|
|
281
|
+
session_id=element_session(session, element),
|
|
282
|
+
)
|
|
283
|
+
except Exception: # noqa: BLE001 - unknown node: no bypass
|
|
284
|
+
return ""
|
|
285
|
+
node = described.get("node", {}) if isinstance(described, dict) else {}
|
|
286
|
+
attrs = node.get("attributes", []) if isinstance(node, dict) else []
|
|
287
|
+
if not isinstance(attrs, list):
|
|
288
|
+
return ""
|
|
289
|
+
for index in range(0, len(attrs) - 1, 2):
|
|
290
|
+
if attrs[index] == "href" and isinstance(attrs[index + 1], str):
|
|
291
|
+
return attrs[index + 1]
|
|
292
|
+
return ""
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def key_press(
|
|
296
|
+
client: Any, session_id: str, key: str, modifiers: list[str] | None = None
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Press and release one key with optional modifiers (real key events)."""
|
|
299
|
+
name, code, windows_code = parse_key(key)
|
|
300
|
+
mods = parse_modifiers(modifiers)
|
|
301
|
+
await _key(client, session_id, "keyDown", name, code, windows_code, mods)
|
|
302
|
+
await _key(client, session_id, "keyUp", name, code, windows_code, mods)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
async def key_chars(
|
|
306
|
+
client: Any, session_id: str, text: str, delay: float = 0.0
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Send text as char key events (for controls ignoring insertText).
|
|
309
|
+
|
|
310
|
+
With delay > 0 each character lands separately like manual typing
|
|
311
|
+
(human-like mode). Total time is bounded: 200 chars max, so even the
|
|
312
|
+
slowest supported delay keeps one call under ~15s.
|
|
313
|
+
|
|
314
|
+
Verified: native <select> type-ahead and date spinbuttons only react to
|
|
315
|
+
text (char) events, not to Input.insertText.
|
|
316
|
+
"""
|
|
317
|
+
import asyncio
|
|
318
|
+
|
|
319
|
+
for char in text[:200]:
|
|
320
|
+
lowered = char.lower()
|
|
321
|
+
code = f"Key{char.upper()}" if char.isalpha() else (
|
|
322
|
+
f"Digit{char}" if char.isdigit() else ""
|
|
323
|
+
)
|
|
324
|
+
vk = ord(char.upper()) if char.isalnum() else 0
|
|
325
|
+
await _key(client, session_id, "rawKeyDown", lowered, code, vk)
|
|
326
|
+
await _key(client, session_id, "char", lowered, code, 0, text=char)
|
|
327
|
+
await _key(client, session_id, "keyUp", lowered, code, vk)
|
|
328
|
+
if delay > 0:
|
|
329
|
+
await asyncio.sleep(min(delay, 0.5))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
HUMAN_CHAR_DELAY = 0.05 # seconds between keystrokes in human-like mode
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def type_text(
|
|
336
|
+
session: AgentSession,
|
|
337
|
+
element: ElementRef,
|
|
338
|
+
text: str,
|
|
339
|
+
clear: bool,
|
|
340
|
+
human: bool = False,
|
|
341
|
+
) -> None:
|
|
342
|
+
"""Focus the field, optionally clear it, insert text as real input.
|
|
343
|
+
|
|
344
|
+
Spinbuttons ignore insertText: they get char key events instead.
|
|
345
|
+
human=True types char-by-char with a small delay like manual typing
|
|
346
|
+
(slower, but indistinguishable from a keyboard for bot detection).
|
|
347
|
+
"""
|
|
348
|
+
client = session.client
|
|
349
|
+
target = element_session(session, element)
|
|
350
|
+
try:
|
|
351
|
+
resolved = await client.send(
|
|
352
|
+
"DOM.resolveNode",
|
|
353
|
+
{"backendNodeId": element.backend_id},
|
|
354
|
+
session_id=target,
|
|
355
|
+
)
|
|
356
|
+
object_id = (resolved.get("object") or {}).get("objectId")
|
|
357
|
+
except Exception as exc: # noqa: BLE001 - resolution failure is a stale ref
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"error: ref_stale - {element.role} {element.name!r} is gone. "
|
|
360
|
+
"call page_snapshot()."
|
|
361
|
+
) from exc
|
|
362
|
+
if not isinstance(object_id, str):
|
|
363
|
+
raise ValueError(
|
|
364
|
+
f"error: ref_stale - {element.role} {element.name!r} is gone. "
|
|
365
|
+
"call page_snapshot()."
|
|
366
|
+
)
|
|
367
|
+
await client.send("DOM.focus", {"objectId": object_id}, session_id=target)
|
|
368
|
+
if clear:
|
|
369
|
+
await key_press(client, target, "a", ["ctrl"])
|
|
370
|
+
await key_press(client, target, "Backspace")
|
|
371
|
+
if element.role == "spinbutton":
|
|
372
|
+
await key_chars(client, target, text)
|
|
373
|
+
elif human:
|
|
374
|
+
await key_chars(client, target, text, delay=HUMAN_CHAR_DELAY)
|
|
375
|
+
else:
|
|
376
|
+
await client.send(
|
|
377
|
+
"Input.insertText", {"text": text}, session_id=target
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
async def scroll_by(client: Any, session_id: str, dx: float, dy: float) -> None:
|
|
382
|
+
"""Real wheel event at the viewport center."""
|
|
383
|
+
metrics = await client.send(
|
|
384
|
+
"Page.getLayoutMetrics", session_id=session_id
|
|
385
|
+
)
|
|
386
|
+
viewport = metrics.get("cssVisualViewport", {}) or {}
|
|
387
|
+
x = float(viewport.get("pageX", 0)) + float(viewport.get("width", 800)) / 2
|
|
388
|
+
y = float(viewport.get("pageY", 0)) + float(viewport.get("height", 600)) / 2
|
|
389
|
+
await _mouse(
|
|
390
|
+
client, session_id, "mouseWheel", x, y, deltaX=dx, deltaY=dy
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def scroll_to(session: AgentSession, element: ElementRef) -> None:
|
|
395
|
+
"""Bring the element into view with a margin for sticky headers."""
|
|
396
|
+
target = element_session(session, element)
|
|
397
|
+
await session.client.send(
|
|
398
|
+
"DOM.scrollIntoViewIfNeeded",
|
|
399
|
+
{"backendNodeId": element.backend_id},
|
|
400
|
+
session_id=target,
|
|
401
|
+
)
|
|
402
|
+
await scroll_by(session.client, session.session_id, 0.0, -80.0)
|
|
403
|
+
await settle_scroll(session.client, session.session_id)
|
|
404
|
+
# Near the page bottom the -80 margin can push a bottom element back
|
|
405
|
+
# below the fold (verified): re-reveal without margin when still hidden.
|
|
406
|
+
try:
|
|
407
|
+
result = await session.client.send(
|
|
408
|
+
"DOM.getContentQuads", {"backendNodeId": element.backend_id},
|
|
409
|
+
session_id=target,
|
|
410
|
+
)
|
|
411
|
+
quads = result.get("quads", [])
|
|
412
|
+
center = quad_center(list(quads[0])) if quads else None
|
|
413
|
+
if center is None:
|
|
414
|
+
return
|
|
415
|
+
metrics = await session.client.send(
|
|
416
|
+
"Page.getLayoutMetrics", session_id=session.session_id
|
|
417
|
+
)
|
|
418
|
+
viewport = metrics.get("cssVisualViewport", {}) or {}
|
|
419
|
+
height = float(viewport.get("height", 0) or 0)
|
|
420
|
+
if not height:
|
|
421
|
+
page = metrics.get("layoutViewport", {}) or {}
|
|
422
|
+
height = float(page.get("clientHeight", 0) or 0)
|
|
423
|
+
if height and not (0 <= center[1] <= height):
|
|
424
|
+
await session.client.send(
|
|
425
|
+
"DOM.scrollIntoViewIfNeeded",
|
|
426
|
+
{"backendNodeId": element.backend_id},
|
|
427
|
+
session_id=target,
|
|
428
|
+
)
|
|
429
|
+
await settle_scroll(session.client, session.session_id)
|
|
430
|
+
except Exception: # noqa: BLE001 - re-reveal is best-effort
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
async def hover(session: AgentSession, element: ElementRef) -> None:
|
|
435
|
+
"""Gradual mouse approach so hover menus appear (spec 11.23)."""
|
|
436
|
+
x, y = await element_center(session, element)
|
|
437
|
+
target = element_session(session, element)
|
|
438
|
+
steps = 8
|
|
439
|
+
await session.client.send(
|
|
440
|
+
"Input.dispatchMouseEvent",
|
|
441
|
+
{"type": "mouseMoved", "x": x - 40.0, "y": y - 20.0},
|
|
442
|
+
session_id=target,
|
|
443
|
+
)
|
|
444
|
+
for step in range(1, steps + 1):
|
|
445
|
+
await _mouse(
|
|
446
|
+
session.client, target, "mouseMoved",
|
|
447
|
+
x - 40.0 + 40.0 * step / steps, y - 20.0 + 20.0 * step / steps,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
async def select_option(
|
|
452
|
+
session: AgentSession, element: ElementRef, value: str
|
|
453
|
+
) -> None:
|
|
454
|
+
"""Native <select> via keyboard type-ahead (no JS). Caller verifies.
|
|
455
|
+
|
|
456
|
+
Type-ahead matching consumes text (char) events: rawKeyDown + char +
|
|
457
|
+
keyUp per character. Plain keyDown/Up alone does not drive matching,
|
|
458
|
+
and Enter would open the native popup instead.
|
|
459
|
+
"""
|
|
460
|
+
if element.role != "combobox":
|
|
461
|
+
raise ValueError(
|
|
462
|
+
f"error: invalid_input - ref {element.ref} is {element.role}, "
|
|
463
|
+
"not a select box. For custom dropdowns: click, then click the option."
|
|
464
|
+
)
|
|
465
|
+
target = element_session(session, element)
|
|
466
|
+
await type_text(session, element, "", clear=False)
|
|
467
|
+
await key_chars(client=session.client, session_id=target, text=value[:30])
|
|
468
|
+
await key_press(session.client, target, "Tab")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def combobox_value(element: ElementRef) -> str:
|
|
472
|
+
"""Current value state of a select box element."""
|
|
473
|
+
for state in element.states:
|
|
474
|
+
if state.startswith('value="') and state.endswith('"'):
|
|
475
|
+
return state[len('value="') : -1]
|
|
476
|
+
return ""
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
async def drag(
|
|
480
|
+
session: AgentSession, source: ElementRef, target_element: ElementRef
|
|
481
|
+
) -> None:
|
|
482
|
+
"""Press, graded moves, release. Both ends must share one session."""
|
|
483
|
+
if element_session(session, source) != element_session(session, target_element):
|
|
484
|
+
raise ValueError(
|
|
485
|
+
"error: invalid_input - drag ends live in different frames. "
|
|
486
|
+
"HTML5 cross-frame DnD is unsupported."
|
|
487
|
+
)
|
|
488
|
+
target = element_session(session, source)
|
|
489
|
+
x1, y1 = await element_center(session, source)
|
|
490
|
+
x2, y2 = await element_center(session, target_element)
|
|
491
|
+
client = session.client
|
|
492
|
+
await _mouse(client, target, "mouseMoved", x1, y1)
|
|
493
|
+
await _mouse(client, target, "mousePressed", x1, y1, button="left",
|
|
494
|
+
clickCount=1)
|
|
495
|
+
for step in range(1, 13):
|
|
496
|
+
await _mouse(
|
|
497
|
+
client, target, "mouseMoved",
|
|
498
|
+
x1 + (x2 - x1) * step / 12, y1 + (y2 - y1) * step / 12,
|
|
499
|
+
)
|
|
500
|
+
await _mouse(client, target, "mouseReleased", x2, y2, button="left",
|
|
501
|
+
clickCount=1)
|
|
502
|
+
# Synthetic mouse drags rarely complete the HTML5 drop handshake, which can
|
|
503
|
+
# leave hit-testing stuck on the last drag target (verified). Escape
|
|
504
|
+
# cancels any stuck drag operation.
|
|
505
|
+
await key_press(client, target, "Escape")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
async def upload_file(
|
|
509
|
+
session: AgentSession,
|
|
510
|
+
element: ElementRef,
|
|
511
|
+
path: str,
|
|
512
|
+
workspace_dir: str,
|
|
513
|
+
) -> None:
|
|
514
|
+
"""Set files on an <input type=file>. Path must live in the workspace."""
|
|
515
|
+
from pathlib import Path
|
|
516
|
+
|
|
517
|
+
candidate = Path(path).resolve()
|
|
518
|
+
root = Path(workspace_dir).resolve()
|
|
519
|
+
if root not in candidate.parents and candidate != root:
|
|
520
|
+
raise ValueError(
|
|
521
|
+
f"error: path_forbidden - {path!r} is outside the workspace. "
|
|
522
|
+
"upload_file paths must live inside the workspace."
|
|
523
|
+
)
|
|
524
|
+
if not candidate.is_file():
|
|
525
|
+
raise ValueError(f"error: file_not_found - no file at {path!r}.")
|
|
526
|
+
if "file" not in element.role and "button" not in element.role:
|
|
527
|
+
raise ValueError(
|
|
528
|
+
f"error: invalid_input - ref {element.ref} is {element.role}, "
|
|
529
|
+
"not a file input."
|
|
530
|
+
)
|
|
531
|
+
await session.client.send(
|
|
532
|
+
"DOM.setFileInputFiles",
|
|
533
|
+
{"files": [str(candidate)], "backendNodeId": element.backend_id},
|
|
534
|
+
session_id=element_session(session, element),
|
|
535
|
+
)
|