browserwright 0.6.2__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.
- browserwright/__init__.py +33 -0
- browserwright/__main__.py +6 -0
- browserwright/_executor/__init__.py +47 -0
- browserwright/_executor/__main__.py +9 -0
- browserwright/_executor/client.py +127 -0
- browserwright/_executor/process.py +652 -0
- browserwright/_executor/protocol.py +152 -0
- browserwright/api.py +66 -0
- browserwright/cdp.py +285 -0
- browserwright/cli.py +741 -0
- browserwright/daemon/__init__.py +8 -0
- browserwright/daemon/_ipc.py +444 -0
- browserwright/daemon/active_tab.py +183 -0
- browserwright/daemon/auth.py +395 -0
- browserwright/daemon/backends/__init__.py +59 -0
- browserwright/daemon/backends/base.py +120 -0
- browserwright/daemon/backends/cloud.py +222 -0
- browserwright/daemon/backends/env.py +119 -0
- browserwright/daemon/backends/extension.py +185 -0
- browserwright/daemon/backends/rdp.py +214 -0
- browserwright/daemon/cli.py +1437 -0
- browserwright/daemon/config.py +380 -0
- browserwright/daemon/doctor.py +179 -0
- browserwright/daemon/errors.py +34 -0
- browserwright/daemon/launch_chrome.py +353 -0
- browserwright/daemon/observability.py +181 -0
- browserwright/daemon/platforms.py +234 -0
- browserwright/daemon/resolver.py +72 -0
- browserwright/daemon/server/__init__.py +6 -0
- browserwright/daemon/server/daemon.py +229 -0
- browserwright/daemon/server/executor_registry.py +434 -0
- browserwright/daemon/server/extension_upstream.py +677 -0
- browserwright/daemon/server/facade.py +375 -0
- browserwright/daemon/server/facade_extension.py +969 -0
- browserwright/daemon/server/listener.py +1058 -0
- browserwright/daemon/server/proxy.py +1991 -0
- browserwright/daemon/server/relay.py +783 -0
- browserwright/daemon/server/state.py +432 -0
- browserwright/daemon/server/upstream.py +266 -0
- browserwright/daemon/userscripts.py +150 -0
- browserwright/discovery.py +213 -0
- browserwright/errors.py +177 -0
- browserwright/health.py +169 -0
- browserwright/install.py +628 -0
- browserwright/memory/__init__.py +15 -0
- browserwright/memory/_md.py +120 -0
- browserwright/memory/_yaml.py +217 -0
- browserwright/memory/global_mem.py +201 -0
- browserwright/memory/repl_mem.py +28 -0
- browserwright/memory/session_decisions.py +53 -0
- browserwright/memory/site_mem.py +381 -0
- browserwright/mode_b_client.py +590 -0
- browserwright/multitask.py +131 -0
- browserwright/output_schema.py +99 -0
- browserwright/primitives/__init__.py +67 -0
- browserwright/primitives/discovery_api.py +79 -0
- browserwright/primitives/http.py +42 -0
- browserwright/primitives/inspect.py +876 -0
- browserwright/primitives/interact.py +518 -0
- browserwright/primitives/page.py +556 -0
- browserwright/primitives/site.py +143 -0
- browserwright/release_install.py +466 -0
- browserwright/repl/__init__.py +6 -0
- browserwright/repl/_namespace.py +106 -0
- browserwright/repl/_smart_goto.py +236 -0
- browserwright/repl/inline.py +180 -0
- browserwright/repl/playwright_handle.py +449 -0
- browserwright/repl/snapshot.py +150 -0
- browserwright/session.py +229 -0
- browserwright/session_create.py +252 -0
- browserwright/session_ctx.py +24 -0
- browserwright/session_registry.py +133 -0
- browserwright/session_runtime.py +133 -0
- browserwright/site_skills_starter/github.com/SKILL.md +14 -0
- browserwright/site_skills_starter/github.com/memory.md +29 -0
- browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
- browserwright/site_skills_starter/google.com/SKILL.md +16 -0
- browserwright/site_skills_starter/google.com/memory.md +27 -0
- browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
- browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
- browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
- browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
- browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
- browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
- browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
- browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
- browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
- browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
- browserwright/skill_doc.py +140 -0
- browserwright/skill_runtime.md +194 -0
- browserwright/subscriptions.py +213 -0
- browserwright/task_runner.py +125 -0
- browserwright/version.py +117 -0
- browserwright-0.6.2.dist-info/METADATA +12 -0
- browserwright-0.6.2.dist-info/RECORD +98 -0
- browserwright-0.6.2.dist-info/WHEEL +5 -0
- browserwright-0.6.2.dist-info/entry_points.txt +3 -0
- browserwright-0.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""Compositor-level input + JS evaluation primitives.
|
|
2
|
+
|
|
3
|
+
v0.5.1 (F-4 catch-up) — primitives ported from ``browser-harness``:
|
|
4
|
+
``type_text``, ``press_key``, ``scroll``, ``fill_input``, ``dispatch_key``,
|
|
5
|
+
``upload_file``, ``wait_for_element``, ``wait_for_network_idle``,
|
|
6
|
+
``drain_events``. Same compositor-vs-DOM trade-off semantics; CDP transport
|
|
7
|
+
goes through ``current_session().cdp.send(method, session=sid, ...)``.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Iterable, Optional, Union
|
|
15
|
+
|
|
16
|
+
from ..errors import CDPError, ElementNotFound
|
|
17
|
+
from ..session import current_session
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _attached_session() -> str:
|
|
21
|
+
sess = current_session()
|
|
22
|
+
if not sess.current_target_id:
|
|
23
|
+
# Transparent reconnect-recovery: re-attach to this session's own tab
|
|
24
|
+
# across daemon restart / extension reconnect / new process. This does
|
|
25
|
+
# NOT steal the user's focused tab — it re-binds the tab this session
|
|
26
|
+
# already owns (anchored on the persisted tab-group id).
|
|
27
|
+
from ..session_runtime import ensure_session_target
|
|
28
|
+
if ensure_session_target(sess):
|
|
29
|
+
return sess.cdp.attach(sess.current_target_id)
|
|
30
|
+
# No tab bound and none to recover. Safe to auto-fallback on EVERY
|
|
31
|
+
# backend now: current_page()'s empty fallback is open() (a fresh
|
|
32
|
+
# working tab in the session's browser), NOT attach_active()/adopt —
|
|
33
|
+
# so it never steals the user's focused tab (docs §Tier B).
|
|
34
|
+
from .page import current_page
|
|
35
|
+
current_page()
|
|
36
|
+
return sess.cdp.attach(sess.current_target_id)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def click_at_xy(x: float, y: float, button: str = "left", clicks: int = 1) -> dict:
|
|
40
|
+
"""Compositor-level click — passes through iframes / shadow / cross-origin."""
|
|
41
|
+
sid = _attached_session()
|
|
42
|
+
sess = current_session()
|
|
43
|
+
for _ in range(int(clicks)):
|
|
44
|
+
sess.cdp.send(
|
|
45
|
+
"Input.dispatchMouseEvent", session=sid,
|
|
46
|
+
type="mousePressed", x=float(x), y=float(y),
|
|
47
|
+
button=button, clickCount=1, buttons=1,
|
|
48
|
+
)
|
|
49
|
+
sess.cdp.send(
|
|
50
|
+
"Input.dispatchMouseEvent", session=sid,
|
|
51
|
+
type="mouseReleased", x=float(x), y=float(y),
|
|
52
|
+
button=button, clickCount=1, buttons=0,
|
|
53
|
+
)
|
|
54
|
+
time.sleep(0.05)
|
|
55
|
+
return {"x": x, "y": y, "button": button, "clicks": clicks}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _has_top_level_return(src: str) -> bool:
|
|
59
|
+
"""``True`` iff ``src`` contains a ``return`` keyword at top level.
|
|
60
|
+
|
|
61
|
+
Top level means not nested inside any ``()``, ``[]``, ``{}``, string,
|
|
62
|
+
template literal, line/block comment, or regex literal. Used by
|
|
63
|
+
``js()`` to decide whether to auto-wrap the expression in an IIFE so
|
|
64
|
+
the caller can write ``js("return foo.bar")`` ergonomically without
|
|
65
|
+
misclassifying already-IIFE expressions like
|
|
66
|
+
``js("(()=>{return arr.map(...)})()")`` (whose ``return`` is nested
|
|
67
|
+
inside parens and must NOT trigger a re-wrap — that was the
|
|
68
|
+
silent-None bug pre-v0.5.5).
|
|
69
|
+
|
|
70
|
+
Template-literal interpolations (``${...}``) are treated as opaque:
|
|
71
|
+
we don't scan inside them. Returns *inside* a template's ``${}``
|
|
72
|
+
won't be detected as top-level, which is fine — that pattern is
|
|
73
|
+
vanishingly rare and the user can pass ``raw=True`` if needed.
|
|
74
|
+
"""
|
|
75
|
+
n = len(src)
|
|
76
|
+
i = 0
|
|
77
|
+
depth = 0
|
|
78
|
+
in_str: Optional[str] = None # quote char, or None
|
|
79
|
+
in_line_comment = False
|
|
80
|
+
in_block_comment = False
|
|
81
|
+
# Tracks whether the previous non-space token could plausibly be
|
|
82
|
+
# followed by a regex literal (vs. a division). Crude but enough
|
|
83
|
+
# to skip /.../ regex bodies without false-positives on a/b/c.
|
|
84
|
+
prev_significant = ""
|
|
85
|
+
|
|
86
|
+
while i < n:
|
|
87
|
+
c = src[i]
|
|
88
|
+
nxt = src[i + 1] if i + 1 < n else ""
|
|
89
|
+
|
|
90
|
+
if in_line_comment:
|
|
91
|
+
if c == "\n":
|
|
92
|
+
in_line_comment = False
|
|
93
|
+
i += 1
|
|
94
|
+
continue
|
|
95
|
+
if in_block_comment:
|
|
96
|
+
if c == "*" and nxt == "/":
|
|
97
|
+
in_block_comment = False
|
|
98
|
+
i += 2
|
|
99
|
+
continue
|
|
100
|
+
i += 1
|
|
101
|
+
continue
|
|
102
|
+
if in_str is not None:
|
|
103
|
+
if c == "\\":
|
|
104
|
+
i += 2
|
|
105
|
+
continue
|
|
106
|
+
if c == in_str:
|
|
107
|
+
in_str = None
|
|
108
|
+
prev_significant = c
|
|
109
|
+
i += 1
|
|
110
|
+
continue
|
|
111
|
+
# Template-literal interpolation: ${ ... }. Skip to the
|
|
112
|
+
# matching '}' — we don't try to scan inside.
|
|
113
|
+
if in_str == "`" and c == "$" and nxt == "{":
|
|
114
|
+
inner_depth = 1
|
|
115
|
+
j = i + 2
|
|
116
|
+
while j < n and inner_depth > 0:
|
|
117
|
+
if src[j] == "{":
|
|
118
|
+
inner_depth += 1
|
|
119
|
+
elif src[j] == "}":
|
|
120
|
+
inner_depth -= 1
|
|
121
|
+
j += 1
|
|
122
|
+
i = j
|
|
123
|
+
continue
|
|
124
|
+
i += 1
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# Comments
|
|
128
|
+
if c == "/" and nxt == "/":
|
|
129
|
+
in_line_comment = True
|
|
130
|
+
i += 2
|
|
131
|
+
continue
|
|
132
|
+
if c == "/" and nxt == "*":
|
|
133
|
+
in_block_comment = True
|
|
134
|
+
i += 2
|
|
135
|
+
continue
|
|
136
|
+
# Regex literal: only if the previous significant char allows
|
|
137
|
+
# a regex (not after an identifier, ')', ']', or numeric literal).
|
|
138
|
+
if c == "/" and prev_significant not in (")", "]", "_", "$"
|
|
139
|
+
) and not (prev_significant.isalnum()):
|
|
140
|
+
# Skip /.../flags
|
|
141
|
+
j = i + 1
|
|
142
|
+
while j < n and src[j] != "/":
|
|
143
|
+
if src[j] == "\\":
|
|
144
|
+
j += 2
|
|
145
|
+
continue
|
|
146
|
+
if src[j] == "\n":
|
|
147
|
+
# Newline before closing '/': not a regex after all.
|
|
148
|
+
break
|
|
149
|
+
j += 1
|
|
150
|
+
else:
|
|
151
|
+
# Hit end-of-string without closing /; bail out of regex.
|
|
152
|
+
j = i
|
|
153
|
+
if j < n and src[j] == "/":
|
|
154
|
+
# Consume trailing flags
|
|
155
|
+
j += 1
|
|
156
|
+
while j < n and src[j].isalpha():
|
|
157
|
+
j += 1
|
|
158
|
+
i = j
|
|
159
|
+
prev_significant = "/"
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
if c in ('"', "'", "`"):
|
|
163
|
+
in_str = c
|
|
164
|
+
i += 1
|
|
165
|
+
continue
|
|
166
|
+
if c in "({[":
|
|
167
|
+
depth += 1
|
|
168
|
+
prev_significant = c
|
|
169
|
+
i += 1
|
|
170
|
+
continue
|
|
171
|
+
if c in ")}]":
|
|
172
|
+
depth -= 1
|
|
173
|
+
prev_significant = c
|
|
174
|
+
i += 1
|
|
175
|
+
continue
|
|
176
|
+
# Top-level "return" keyword. Reject ``foo.return`` (member access:
|
|
177
|
+
# reserved words are legal property names in JS) by treating ``.``
|
|
178
|
+
# as a keyword-blocker. Same for optional-chain ``?.return``.
|
|
179
|
+
if (depth == 0 and c == "r" and src[i:i + 6] == "return"):
|
|
180
|
+
before_ok = (i == 0) or not (src[i - 1].isalnum()
|
|
181
|
+
or src[i - 1] in ("_", "$", "."))
|
|
182
|
+
after_ok = (i + 6 >= n) or not (src[i + 6].isalnum()
|
|
183
|
+
or src[i + 6] in ("_", "$"))
|
|
184
|
+
if before_ok and after_ok:
|
|
185
|
+
return True
|
|
186
|
+
if not c.isspace():
|
|
187
|
+
prev_significant = c
|
|
188
|
+
i += 1
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def js(expression: str, target_id: Optional[str] = None, *,
|
|
193
|
+
raw: bool = False) -> Any:
|
|
194
|
+
"""Evaluate JS in the page via ``Runtime.evaluate``.
|
|
195
|
+
|
|
196
|
+
If ``expression`` contains a *top-level* ``return`` keyword it's
|
|
197
|
+
wrapped in an IIFE so the caller can write ``js("return foo.bar")``
|
|
198
|
+
ergonomically. The scanner skips strings, template literals,
|
|
199
|
+
comments, and any ``return`` nested inside ``()/[]/{}`` — so
|
|
200
|
+
already-IIFE expressions like ``js("(()=>{ return arr.map(...) })()")``
|
|
201
|
+
are NOT re-wrapped (pre-v0.5.5 they were, which silently produced
|
|
202
|
+
``None`` because the outer wrapper had no return).
|
|
203
|
+
|
|
204
|
+
Pass ``raw=True`` to skip auto-wrap entirely (escape hatch for
|
|
205
|
+
expressions where the scanner misfires).
|
|
206
|
+
|
|
207
|
+
``target_id`` lets you target a specific iframe / popup via
|
|
208
|
+
``iframe_target(url)``.
|
|
209
|
+
|
|
210
|
+
Returns the deserialized result, or ``None`` when JS returned
|
|
211
|
+
``undefined``. Raises ``CDPError`` when the result is
|
|
212
|
+
non-serializable (DOM nodes, Map/Set, circular refs, functions) —
|
|
213
|
+
wrap the relevant fields with ``JSON.stringify()`` or return
|
|
214
|
+
primitive properties instead. Previously such results silently
|
|
215
|
+
became ``None``, which was the second half of the v0.5.4
|
|
216
|
+
silent-None bug.
|
|
217
|
+
"""
|
|
218
|
+
sess = current_session()
|
|
219
|
+
sid = sess.cdp.attach(target_id) if target_id else _attached_session()
|
|
220
|
+
code = expression
|
|
221
|
+
if not raw and _has_top_level_return(expression):
|
|
222
|
+
code = f"(function(){{ {expression} }})()"
|
|
223
|
+
try:
|
|
224
|
+
res = sess.cdp.send(
|
|
225
|
+
"Runtime.evaluate", session=sid,
|
|
226
|
+
expression=code, returnByValue=True, awaitPromise=True,
|
|
227
|
+
)
|
|
228
|
+
except CDPError as e:
|
|
229
|
+
# Surface JS errors with their actual text — agents debug from these.
|
|
230
|
+
raise CDPError(method="Runtime.evaluate",
|
|
231
|
+
params={"expression": expression},
|
|
232
|
+
cdp_message=e.cdp_message) from e
|
|
233
|
+
if "exceptionDetails" in res:
|
|
234
|
+
det = res["exceptionDetails"]
|
|
235
|
+
text = det.get("exception", {}).get("description") or det.get("text", "JS exception")
|
|
236
|
+
raise CDPError(method="Runtime.evaluate",
|
|
237
|
+
params={"expression": expression}, cdp_message=text)
|
|
238
|
+
result = res.get("result", {})
|
|
239
|
+
if "value" in result:
|
|
240
|
+
return result["value"]
|
|
241
|
+
# No ``value`` field. CDP omits it for two distinct cases — distinguish:
|
|
242
|
+
# * ``undefined`` — legitimate "function returned no value", map to None
|
|
243
|
+
# * everything else (object/function/symbol with no value) —
|
|
244
|
+
# non-serializable; the silent-None trap pre-v0.5.5.
|
|
245
|
+
ty = result.get("type")
|
|
246
|
+
if ty == "undefined":
|
|
247
|
+
return None
|
|
248
|
+
desc = (result.get("description") or result.get("subtype")
|
|
249
|
+
or ty or "<unknown>")
|
|
250
|
+
raise CDPError(
|
|
251
|
+
method="Runtime.evaluate",
|
|
252
|
+
params={"expression": expression},
|
|
253
|
+
cdp_message=(
|
|
254
|
+
f"non-serializable JS result (type={ty!r}, desc={desc!r}). "
|
|
255
|
+
f"Runtime.evaluate with returnByValue cannot serialize DOM "
|
|
256
|
+
f"nodes, Map/Set, functions, or circular refs. Wrap the "
|
|
257
|
+
f"fields you need with JSON.stringify() or return primitive "
|
|
258
|
+
f"properties (e.g. ``el.id``, ``el.textContent``) instead."
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---- keyboard ----------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# (key → (windowsVirtualKeyCode, code, text)) — covers the special keys
|
|
267
|
+
# whose .keyCode / .code listeners DOM frameworks check. Single-char keys
|
|
268
|
+
# fall through to ``ord(key[0])`` + ``key`` for code.
|
|
269
|
+
_KEYS: dict[str, tuple[int, str, str]] = {
|
|
270
|
+
"Enter": (13, "Enter", "\r"), "Tab": (9, "Tab", "\t"),
|
|
271
|
+
"Backspace": (8, "Backspace", ""), "Escape": (27, "Escape", ""),
|
|
272
|
+
"Delete": (46, "Delete", ""), " ": (32, "Space", " "),
|
|
273
|
+
"ArrowLeft": (37, "ArrowLeft", ""), "ArrowUp": (38, "ArrowUp", ""),
|
|
274
|
+
"ArrowRight": (39, "ArrowRight", ""), "ArrowDown": (40, "ArrowDown", ""),
|
|
275
|
+
"Home": (36, "Home", ""), "End": (35, "End", ""),
|
|
276
|
+
"PageUp": (33, "PageUp", ""), "PageDown": (34, "PageDown", ""),
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def type_text(text: str) -> None:
|
|
281
|
+
"""Insert ``text`` at the focused element via ``Input.insertText``.
|
|
282
|
+
|
|
283
|
+
Bypasses framework event listeners — fast and good for plain inputs.
|
|
284
|
+
Use ``fill_input`` when the site is a framework-controlled input
|
|
285
|
+
(React controlled, Vue v-model, etc.) that needs synthetic
|
|
286
|
+
``input``/``change`` events to update its bound state.
|
|
287
|
+
"""
|
|
288
|
+
sess = current_session()
|
|
289
|
+
sid = _attached_session()
|
|
290
|
+
sess.cdp.send("Input.insertText", session=sid, text=text)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def press_key(key: str, modifiers: int = 0) -> None:
|
|
294
|
+
"""Dispatch a real keyDown / (optional char) / keyUp sequence.
|
|
295
|
+
|
|
296
|
+
``modifiers`` bitfield: 1=Alt, 2=Ctrl, 4=Meta(Cmd), 8=Shift. Special
|
|
297
|
+
keys (Enter/Tab/Arrow*/Backspace/etc.) carry their virtual keycodes
|
|
298
|
+
so listeners checking ``e.keyCode`` / ``e.key`` all fire correctly.
|
|
299
|
+
"""
|
|
300
|
+
vk, code, text = _KEYS.get(
|
|
301
|
+
key,
|
|
302
|
+
(ord(key[0]) if len(key) == 1 else 0,
|
|
303
|
+
key,
|
|
304
|
+
key if len(key) == 1 else ""),
|
|
305
|
+
)
|
|
306
|
+
base = {
|
|
307
|
+
"key": key, "code": code, "modifiers": modifiers,
|
|
308
|
+
"windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk,
|
|
309
|
+
}
|
|
310
|
+
sess = current_session()
|
|
311
|
+
sid = _attached_session()
|
|
312
|
+
if text:
|
|
313
|
+
sess.cdp.send("Input.dispatchKeyEvent", session=sid,
|
|
314
|
+
type="keyDown", text=text, **base)
|
|
315
|
+
if len(text) == 1:
|
|
316
|
+
sess.cdp.send("Input.dispatchKeyEvent", session=sid,
|
|
317
|
+
type="char", text=text, **base)
|
|
318
|
+
else:
|
|
319
|
+
sess.cdp.send("Input.dispatchKeyEvent", session=sid,
|
|
320
|
+
type="keyDown", **base)
|
|
321
|
+
sess.cdp.send("Input.dispatchKeyEvent", session=sid,
|
|
322
|
+
type="keyUp", **base)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def scroll(x: float, y: float, dy: float = -300, dx: float = 0) -> None:
|
|
326
|
+
"""Wheel scroll at ``(x, y)``. ``dy`` negative = scroll up (consistent
|
|
327
|
+
with browser-harness convention)."""
|
|
328
|
+
sess = current_session()
|
|
329
|
+
sid = _attached_session()
|
|
330
|
+
sess.cdp.send(
|
|
331
|
+
"Input.dispatchMouseEvent", session=sid,
|
|
332
|
+
type="mouseWheel", x=float(x), y=float(y),
|
|
333
|
+
deltaX=float(dx), deltaY=float(dy),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def fill_input(selector: str, text: str, *, clear_first: bool = True,
|
|
338
|
+
timeout: float = 0.0) -> None:
|
|
339
|
+
"""Focus the element matched by ``selector``, optionally clear it,
|
|
340
|
+
type ``text`` via real key events, then dispatch synthetic
|
|
341
|
+
``input``/``change`` events so framework-bound state updates.
|
|
342
|
+
|
|
343
|
+
Raises ``ElementNotFound`` if the selector doesn't match. Pass
|
|
344
|
+
``timeout > 0`` to wait for late-rendered elements (e.g. after a
|
|
345
|
+
route change).
|
|
346
|
+
"""
|
|
347
|
+
if timeout > 0:
|
|
348
|
+
if not wait_for_element(selector, timeout=timeout):
|
|
349
|
+
raise ElementNotFound(selector=selector, timeout=timeout)
|
|
350
|
+
focused = js(
|
|
351
|
+
f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
|
|
352
|
+
f"if(!e)return false;e.focus();return true;}})()"
|
|
353
|
+
)
|
|
354
|
+
if not focused:
|
|
355
|
+
raise ElementNotFound(selector=selector)
|
|
356
|
+
if clear_first:
|
|
357
|
+
# Select-all via the platform shortcut (Cmd on macOS, Ctrl
|
|
358
|
+
# elsewhere). Done as raw key events because press_key() emits
|
|
359
|
+
# a 'char' for single-char keys, which would type a literal "a"
|
|
360
|
+
# under modifiers instead of triggering select-all.
|
|
361
|
+
mods = 4 if sys.platform == "darwin" else 2
|
|
362
|
+
select_all = {
|
|
363
|
+
"key": "a", "code": "KeyA", "modifiers": mods,
|
|
364
|
+
"windowsVirtualKeyCode": 65, "nativeVirtualKeyCode": 65,
|
|
365
|
+
}
|
|
366
|
+
sess = current_session()
|
|
367
|
+
sid = _attached_session()
|
|
368
|
+
sess.cdp.send("Input.dispatchKeyEvent", session=sid,
|
|
369
|
+
type="rawKeyDown", **select_all)
|
|
370
|
+
sess.cdp.send("Input.dispatchKeyEvent", session=sid,
|
|
371
|
+
type="keyUp", **select_all)
|
|
372
|
+
press_key("Backspace")
|
|
373
|
+
for ch in text:
|
|
374
|
+
press_key(ch)
|
|
375
|
+
js(
|
|
376
|
+
f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
|
|
377
|
+
f"if(!e)return;"
|
|
378
|
+
f"e.dispatchEvent(new Event('input',{{bubbles:true}}));"
|
|
379
|
+
f"e.dispatchEvent(new Event('change',{{bubbles:true}}));}})();"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ---- DOM-level dispatch -----------------------------------------------
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
_DOM_KC = {
|
|
387
|
+
"Enter": 13, "Tab": 9, "Escape": 27, "Backspace": 8, " ": 32,
|
|
388
|
+
"ArrowLeft": 37, "ArrowUp": 38, "ArrowRight": 39, "ArrowDown": 40,
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def dispatch_key(selector: str, key: str = "Enter",
|
|
393
|
+
event: str = "keypress") -> None:
|
|
394
|
+
"""Dispatch a synthetic DOM ``KeyboardEvent`` on the matched element.
|
|
395
|
+
|
|
396
|
+
Use when a site's listener reacts to DOM events on a specific element
|
|
397
|
+
more reliably than to raw CDP input events fired at compositor level
|
|
398
|
+
(some React/Vue forms behave this way).
|
|
399
|
+
"""
|
|
400
|
+
kc = _DOM_KC.get(key, ord(key) if len(key) == 1 else 0)
|
|
401
|
+
js(
|
|
402
|
+
f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
|
|
403
|
+
f"if(e){{e.focus();"
|
|
404
|
+
f"e.dispatchEvent(new KeyboardEvent({json.dumps(event)},"
|
|
405
|
+
f"{{key:{json.dumps(key)},code:{json.dumps(key)},"
|
|
406
|
+
f"keyCode:{kc},which:{kc},bubbles:true}}));}}}})()"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ---- upload -----------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def upload_file(selector: str, path: Union[str, Iterable[str]]) -> None:
|
|
414
|
+
"""Set files on a ``<input type=file>`` via ``DOM.setFileInputFiles``.
|
|
415
|
+
|
|
416
|
+
``path`` must be an absolute filesystem path (or a list of them for
|
|
417
|
+
multi-file inputs). Raises ``ElementNotFound`` if the selector
|
|
418
|
+
doesn't match.
|
|
419
|
+
"""
|
|
420
|
+
sess = current_session()
|
|
421
|
+
sid = _attached_session()
|
|
422
|
+
doc = sess.cdp.send("DOM.getDocument", session=sid, depth=-1)
|
|
423
|
+
res = sess.cdp.send("DOM.querySelector", session=sid,
|
|
424
|
+
nodeId=doc["root"]["nodeId"], selector=selector)
|
|
425
|
+
nid = res.get("nodeId")
|
|
426
|
+
if not nid:
|
|
427
|
+
raise ElementNotFound(selector=selector)
|
|
428
|
+
files = [path] if isinstance(path, str) else list(path)
|
|
429
|
+
sess.cdp.send("DOM.setFileInputFiles", session=sid,
|
|
430
|
+
files=files, nodeId=nid)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# ---- waiting + events -------------------------------------------------
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def wait_for_element(selector: str, *, timeout: float = 10.0,
|
|
437
|
+
visible: bool = False) -> bool:
|
|
438
|
+
"""Poll until ``document.querySelector(selector)`` matches, or
|
|
439
|
+
timeout. ``visible=True`` additionally requires the element to be
|
|
440
|
+
rendered (uses ``checkVisibility()`` when available, falls back to
|
|
441
|
+
CSS inspection on older Chrome).
|
|
442
|
+
|
|
443
|
+
``wait_for_load`` is not enough for SPAs — ``readyState`` flips to
|
|
444
|
+
``complete`` before the framework renders. Use this after actions
|
|
445
|
+
that trigger async rendering (route changes, data fetches).
|
|
446
|
+
"""
|
|
447
|
+
if visible:
|
|
448
|
+
check = (
|
|
449
|
+
f"(()=>{{const e=document.querySelector({json.dumps(selector)});"
|
|
450
|
+
f"if(!e)return false;"
|
|
451
|
+
f"if(typeof e.checkVisibility==='function')"
|
|
452
|
+
f"return e.checkVisibility({{checkOpacity:true,checkVisibilityCSS:true}});"
|
|
453
|
+
f"const s=getComputedStyle(e);"
|
|
454
|
+
f"return s.display!=='none'&&s.visibility!=='hidden'"
|
|
455
|
+
f"&&s.opacity!=='0'}})()"
|
|
456
|
+
)
|
|
457
|
+
else:
|
|
458
|
+
check = f"!!document.querySelector({json.dumps(selector)})"
|
|
459
|
+
deadline = time.monotonic() + timeout
|
|
460
|
+
while time.monotonic() < deadline:
|
|
461
|
+
try:
|
|
462
|
+
if js(check):
|
|
463
|
+
return True
|
|
464
|
+
except CDPError:
|
|
465
|
+
pass
|
|
466
|
+
time.sleep(0.3)
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def drain_events(session: Optional[str] = None) -> list[dict]:
|
|
471
|
+
"""Pop accumulated CDP events from the daemon's per-session buffer.
|
|
472
|
+
|
|
473
|
+
Returns the list (possibly empty) and clears the buffer. Used by
|
|
474
|
+
``wait_for_network_idle`` and similar event-watching helpers.
|
|
475
|
+
``session=None`` drains the currently attached session.
|
|
476
|
+
"""
|
|
477
|
+
sess = current_session()
|
|
478
|
+
sid = session or sess.cdp.attach(sess.current_target_id) if sess.current_target_id else None
|
|
479
|
+
return sess.cdp.drain_events(session=sid)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def wait_for_network_idle(*, timeout: float = 10.0,
|
|
483
|
+
idle_ms: int = 500) -> bool:
|
|
484
|
+
"""Wait until no in-flight requests AND no Network.* event has
|
|
485
|
+
arrived for ``idle_ms`` milliseconds.
|
|
486
|
+
|
|
487
|
+
Useful after form submits, SPA route transitions, and any action
|
|
488
|
+
that triggers XHR/fetch without a visible DOM change. Filters
|
|
489
|
+
events to the active session so a background tab's polling/SSE
|
|
490
|
+
can't poison the idle window.
|
|
491
|
+
"""
|
|
492
|
+
sess = current_session()
|
|
493
|
+
sid = (sess.cdp.attach(sess.current_target_id)
|
|
494
|
+
if sess.current_target_id else None)
|
|
495
|
+
deadline = time.monotonic() + timeout
|
|
496
|
+
last_activity = time.monotonic()
|
|
497
|
+
inflight: set[str] = set()
|
|
498
|
+
while time.monotonic() < deadline:
|
|
499
|
+
for e in sess.cdp.drain_events(session=sid):
|
|
500
|
+
method = e.get("method") or ""
|
|
501
|
+
params = e.get("params") or {}
|
|
502
|
+
if method == "Network.requestWillBeSent":
|
|
503
|
+
rid = params.get("requestId")
|
|
504
|
+
if rid:
|
|
505
|
+
inflight.add(rid)
|
|
506
|
+
last_activity = time.monotonic()
|
|
507
|
+
elif method in ("Network.loadingFinished",
|
|
508
|
+
"Network.loadingFailed"):
|
|
509
|
+
rid = params.get("requestId")
|
|
510
|
+
if rid:
|
|
511
|
+
inflight.discard(rid)
|
|
512
|
+
last_activity = time.monotonic()
|
|
513
|
+
elif method.startswith("Network."):
|
|
514
|
+
last_activity = time.monotonic()
|
|
515
|
+
if not inflight and (time.monotonic() - last_activity) * 1000 >= idle_ms:
|
|
516
|
+
return True
|
|
517
|
+
time.sleep(0.1)
|
|
518
|
+
return False
|