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.
Files changed (98) hide show
  1. browserwright/__init__.py +33 -0
  2. browserwright/__main__.py +6 -0
  3. browserwright/_executor/__init__.py +47 -0
  4. browserwright/_executor/__main__.py +9 -0
  5. browserwright/_executor/client.py +127 -0
  6. browserwright/_executor/process.py +652 -0
  7. browserwright/_executor/protocol.py +152 -0
  8. browserwright/api.py +66 -0
  9. browserwright/cdp.py +285 -0
  10. browserwright/cli.py +741 -0
  11. browserwright/daemon/__init__.py +8 -0
  12. browserwright/daemon/_ipc.py +444 -0
  13. browserwright/daemon/active_tab.py +183 -0
  14. browserwright/daemon/auth.py +395 -0
  15. browserwright/daemon/backends/__init__.py +59 -0
  16. browserwright/daemon/backends/base.py +120 -0
  17. browserwright/daemon/backends/cloud.py +222 -0
  18. browserwright/daemon/backends/env.py +119 -0
  19. browserwright/daemon/backends/extension.py +185 -0
  20. browserwright/daemon/backends/rdp.py +214 -0
  21. browserwright/daemon/cli.py +1437 -0
  22. browserwright/daemon/config.py +380 -0
  23. browserwright/daemon/doctor.py +179 -0
  24. browserwright/daemon/errors.py +34 -0
  25. browserwright/daemon/launch_chrome.py +353 -0
  26. browserwright/daemon/observability.py +181 -0
  27. browserwright/daemon/platforms.py +234 -0
  28. browserwright/daemon/resolver.py +72 -0
  29. browserwright/daemon/server/__init__.py +6 -0
  30. browserwright/daemon/server/daemon.py +229 -0
  31. browserwright/daemon/server/executor_registry.py +434 -0
  32. browserwright/daemon/server/extension_upstream.py +677 -0
  33. browserwright/daemon/server/facade.py +375 -0
  34. browserwright/daemon/server/facade_extension.py +969 -0
  35. browserwright/daemon/server/listener.py +1058 -0
  36. browserwright/daemon/server/proxy.py +1991 -0
  37. browserwright/daemon/server/relay.py +783 -0
  38. browserwright/daemon/server/state.py +432 -0
  39. browserwright/daemon/server/upstream.py +266 -0
  40. browserwright/daemon/userscripts.py +150 -0
  41. browserwright/discovery.py +213 -0
  42. browserwright/errors.py +177 -0
  43. browserwright/health.py +169 -0
  44. browserwright/install.py +628 -0
  45. browserwright/memory/__init__.py +15 -0
  46. browserwright/memory/_md.py +120 -0
  47. browserwright/memory/_yaml.py +217 -0
  48. browserwright/memory/global_mem.py +201 -0
  49. browserwright/memory/repl_mem.py +28 -0
  50. browserwright/memory/session_decisions.py +53 -0
  51. browserwright/memory/site_mem.py +381 -0
  52. browserwright/mode_b_client.py +590 -0
  53. browserwright/multitask.py +131 -0
  54. browserwright/output_schema.py +99 -0
  55. browserwright/primitives/__init__.py +67 -0
  56. browserwright/primitives/discovery_api.py +79 -0
  57. browserwright/primitives/http.py +42 -0
  58. browserwright/primitives/inspect.py +876 -0
  59. browserwright/primitives/interact.py +518 -0
  60. browserwright/primitives/page.py +556 -0
  61. browserwright/primitives/site.py +143 -0
  62. browserwright/release_install.py +466 -0
  63. browserwright/repl/__init__.py +6 -0
  64. browserwright/repl/_namespace.py +106 -0
  65. browserwright/repl/_smart_goto.py +236 -0
  66. browserwright/repl/inline.py +180 -0
  67. browserwright/repl/playwright_handle.py +449 -0
  68. browserwright/repl/snapshot.py +150 -0
  69. browserwright/session.py +229 -0
  70. browserwright/session_create.py +252 -0
  71. browserwright/session_ctx.py +24 -0
  72. browserwright/session_registry.py +133 -0
  73. browserwright/session_runtime.py +133 -0
  74. browserwright/site_skills_starter/github.com/SKILL.md +14 -0
  75. browserwright/site_skills_starter/github.com/memory.md +29 -0
  76. browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
  77. browserwright/site_skills_starter/google.com/SKILL.md +16 -0
  78. browserwright/site_skills_starter/google.com/memory.md +27 -0
  79. browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
  80. browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
  81. browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
  82. browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
  83. browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
  84. browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
  85. browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
  86. browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
  87. browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
  88. browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
  89. browserwright/skill_doc.py +140 -0
  90. browserwright/skill_runtime.md +194 -0
  91. browserwright/subscriptions.py +213 -0
  92. browserwright/task_runner.py +125 -0
  93. browserwright/version.py +117 -0
  94. browserwright-0.6.2.dist-info/METADATA +12 -0
  95. browserwright-0.6.2.dist-info/RECORD +98 -0
  96. browserwright-0.6.2.dist-info/WHEEL +5 -0
  97. browserwright-0.6.2.dist-info/entry_points.txt +3 -0
  98. 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