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,556 @@
1
+ """Navigation / tab primitives."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from typing import Any
6
+
7
+ from ..errors import CDPError, PageLoadFailed
8
+ from ..session import current_session
9
+
10
+
11
+ _INTERNAL = ("chrome://", "chrome-untrusted://", "devtools://",
12
+ "chrome-extension://", "about:")
13
+
14
+
15
+ def _is_nonattachable_internal_url_error(msg: str | None) -> bool:
16
+ """Does this CDP/daemon error mean 'the active tab is an internal page the
17
+ debugger can't attach to'?
18
+
19
+ Chrome's ``chrome.debugger.attach`` rejects internal surfaces with messages
20
+ like ``Cannot access a chrome-extension:// URL`` /
21
+ ``Cannot access contents of url "chrome://..."`` /
22
+ ``Cannot attach to this target (devtools://...)``. We match on the *internal
23
+ scheme* appearing alongside an access/attach refusal, so the detection
24
+ generalizes across schemes (chrome://, chrome-extension://, devtools://,
25
+ chrome-untrusted://) and Chrome's slightly-varying wording — not one
26
+ hardcoded string.
27
+ """
28
+ if not msg:
29
+ return False
30
+ low = msg.lower()
31
+ mentions_internal = any(scheme in low for scheme in _INTERNAL)
32
+ refuses = ("cannot access" in low or "cannot attach" in low
33
+ or "cannot be attached" in low)
34
+ return mentions_internal and refuses
35
+
36
+
37
+ def attach_active() -> dict:
38
+ """First-class verb: bind the session to its "active" tab.
39
+
40
+ Unified across backends — the daemon dispatches by the session's
41
+ (immutable) backend, so this primitive never branches:
42
+ - extension: adopt the user's currently-focused-window active tab into
43
+ this session's tab group (the analogue of rdp's owned Chrome).
44
+ - rdp: the daemon owns the Chrome, so "active" = the session's current
45
+ front target (most-recently-fronted), created+attached if none.
46
+
47
+ Returns ``{targetId, tabId, url, title}``. The Session's current target
48
+ is set so subsequent primitives (``js``, ``goto_url``, ``capture_screenshot``)
49
+ operate on this tab.
50
+
51
+ The returned ``targetId`` stays valid across heredocs as long as the tab
52
+ is open and the daemon is alive — print it, capture it in the agent's
53
+ conversation, and use ``switch_tab(targetId)`` in subsequent heredocs to
54
+ re-bind without another ``attach_active()`` call. That's more
55
+ deterministic than re-attaching the focused tab on every heredoc,
56
+ which can drift if the user clicks another window between calls.
57
+ See SKILL.md "Persisting a tab handle across heredocs".
58
+ """
59
+ from collections import deque as _deque
60
+ sess = current_session()
61
+ # Route through the long-lived ws (sess.cdp), NOT a CLI subprocess. The
62
+ # subprocess opens its own short-lived client connection — daemon binds
63
+ # the local sessionId to that ephemeral client and discards it on
64
+ # disconnect, so by the time the caller hands the sid to a primitive on
65
+ # its own connection the proxy answers "unknown sessionId".
66
+ try:
67
+ result = sess.cdp.send("BrowserwrightDaemon.attachActiveTab")
68
+ except CDPError as e:
69
+ # The user's *focused* tab may be an internal page Chrome's debugger
70
+ # refuses to attach to (chrome://, chrome-extension://, devtools://, a
71
+ # New Tab Page, …). That's not fatal — it just means "the active tab
72
+ # isn't drivable". Fall back to open(): a fresh attachable working tab
73
+ # in this session's browser the agent can drive. Generic — any
74
+ # non-attachable internal URL triggers it, no scheme hardcoded.
75
+ if _is_nonattachable_internal_url_error(e.cdp_message):
76
+ return open("about:blank")
77
+ raise
78
+ if not result:
79
+ raise CDPError(
80
+ method="BrowserwrightDaemon.attachActiveTab",
81
+ params={},
82
+ cdp_message="empty response from daemon",
83
+ )
84
+ target_id = result.get("targetId")
85
+ sid = result.get("sessionId")
86
+ if not isinstance(target_id, str) or not isinstance(sid, str):
87
+ raise CDPError(
88
+ method="BrowserwrightDaemon.attachActiveTab",
89
+ params={},
90
+ cdp_message=f"malformed daemon response: {result!r}",
91
+ )
92
+ sess.current_target_id = target_id
93
+ from ..session_runtime import persist_target
94
+ persist_target(target_id, sess=sess)
95
+ # Register the daemon-minted session in CDPSession's tables so subsequent
96
+ # send(method, session=sid) calls work, mirroring CDPSession.attach()'s
97
+ # post-attach state. We skip the regular Page/Runtime/DOM/Network.enable
98
+ # bootstrap because attach_active routes through the extension backend
99
+ # which auto-enables those via the relay's session setup.
100
+ cdp_session = sess.cdp
101
+ from ..cdp import _EVENT_RING_LIMIT
102
+ cdp_session._sessions[target_id] = sid # type: ignore[attr-defined]
103
+ cdp_session._events.setdefault(sid, _deque(maxlen=_EVENT_RING_LIMIT)) # type: ignore[attr-defined]
104
+ # Best-effort domain enables (matching CDPSession.attach()). Errors are
105
+ # tolerated since some Chrome builds noop on certain domains.
106
+ for domain in ("Page", "Runtime", "DOM", "Network"):
107
+ try:
108
+ cdp_session.send(f"{domain}.enable", session=sid)
109
+ except CDPError:
110
+ pass
111
+ return {
112
+ "targetId": target_id,
113
+ "tabId": result.get("tabId"),
114
+ "url": result.get("url", ""),
115
+ "title": result.get("title", ""),
116
+ }
117
+
118
+
119
+ def attach_readonly(target_id: str) -> str:
120
+ """Open a read-only secondary session on ``target_id`` (daemon v0.3 H7).
121
+
122
+ Returns the daemon-assigned sessionId. The caller can ``drain_events()``
123
+ or call ``cdp(..., session_id=sid)`` for read-only queries (e.g.
124
+ ``Runtime.evaluate`` will be rejected by the daemon with ``-32602`` per
125
+ the H7 contract; ``DOM.getDocument`` etc. likewise).
126
+
127
+ Practical pattern: a monitoring task tails the page another agent is
128
+ operating on. Use this primitive instead of ``switch_tab`` so you don't
129
+ yank the foreground from under them.
130
+ """
131
+ sess = current_session()
132
+ return sess.cdp.attach_readonly(target_id)
133
+
134
+
135
+ def list_tabs(include_chrome: bool = True) -> list[dict]:
136
+ """The session's tabs ``[{targetId, url, title, attached}]``.
137
+
138
+ Unified across backends (docs §Tier B): the daemon scopes enumeration to
139
+ the session's browser (extension = the session's tab group; rdp = the
140
+ daemon-owned Chrome's targets). Returns ``[]`` when the session has no
141
+ tabs — an empty session is a legitimate state, not an error.
142
+ """
143
+ sess = current_session()
144
+ res = sess.cdp.send("Target.getTargets")
145
+ out: list[dict] = []
146
+ for t in res.get("targetInfos", []):
147
+ if t.get("type") != "page":
148
+ continue
149
+ if not include_chrome and t.get("url", "").startswith(_INTERNAL):
150
+ continue
151
+ out.append({
152
+ "targetId": t.get("targetId"),
153
+ "url": t.get("url", ""),
154
+ "title": t.get("title", ""),
155
+ "attached": t.get("attached", False),
156
+ })
157
+ return out
158
+
159
+
160
+ def current_tab() -> dict | None:
161
+ """The tab Skill is currently bound to, or ``None`` if none yet.
162
+
163
+ Unified across backends (docs §Tier B): returns the current binding (may
164
+ be stale / a chrome:// page) or ``None`` when the session hasn't picked a
165
+ tab. ``None`` is a legitimate "no tab bound yet" state — call
166
+ ``current_page()`` (auto-opens) or ``attach_active()`` to get one.
167
+ """
168
+ sess = current_session()
169
+ if not sess.current_target_id:
170
+ # Transparent reconnect-recovery before declaring "no tab".
171
+ from ..session_runtime import ensure_session_target
172
+ ensure_session_target(sess)
173
+ if sess.current_target_id:
174
+ for t in list_tabs():
175
+ if t["targetId"] == sess.current_target_id:
176
+ return t
177
+ return None
178
+
179
+
180
+ def switch_tab(target) -> dict:
181
+ """Bind the current Session to ``target``.
182
+
183
+ ``target`` = targetId string or a dict carrying ``targetId``.
184
+
185
+ Primary use case is heredoc-to-heredoc continuity: capture the
186
+ ``targetId`` from ``attach_active()`` / ``new_tab()`` /
187
+ ``open_background()``, then call ``switch_tab(targetId)`` at the top
188
+ of subsequent heredocs to re-bind to the same tab without going
189
+ through another attach. Cheaper and more deterministic than
190
+ ``attach_active()`` (which always grabs the currently-focused tab
191
+ and can drift if the user clicks another window between heredocs).
192
+
193
+ Raises ``CDPError`` with an actionable message when the target no
194
+ longer exists (tab closed since the handle was issued).
195
+ """
196
+ if isinstance(target, dict):
197
+ target_id = target.get("targetId")
198
+ else:
199
+ target_id = target
200
+ if not target_id:
201
+ raise ValueError("switch_tab: missing targetId")
202
+ sess = current_session()
203
+ try:
204
+ sess.cdp.attach(target_id)
205
+ except CDPError as e:
206
+ raise CDPError(
207
+ method="Target.attachToTarget",
208
+ params={"targetId": target_id},
209
+ cdp_message=(
210
+ f"switch_tab: target {target_id!r} no longer exists "
211
+ f"(tab likely closed, or daemon restarted since the "
212
+ f"handle was issued). Call `attach_active()` "
213
+ f"(extension backend) or `new_tab(url)` to get a fresh "
214
+ f"handle. Original CDP error: {e.cdp_message}"
215
+ ),
216
+ ) from e
217
+ sess.current_target_id = target_id
218
+ from ..session_runtime import persist_target
219
+ persist_target(target_id, sess=sess)
220
+ try:
221
+ sess.cdp.send("Target.activateTarget", targetId=target_id)
222
+ except CDPError:
223
+ pass
224
+ return {"targetId": target_id}
225
+
226
+
227
+ def open(url: str = "about:blank", *, background: bool = True) -> dict:
228
+ """Open a new working tab in this session's browser, attach, bind as
229
+ current. The unified tab-opening primitive (docs §Tier B) — replaces
230
+ both ``new_tab`` and ``open_background``.
231
+
232
+ Returns ``{targetId, tabId, url, title}``. The daemon dispatches by the
233
+ session's (immutable) backend, so this primitive never branches:
234
+ - extension: opens the tab in this session's tab group; ``background=``
235
+ is honored (``True`` = don't steal the user's focus).
236
+ - rdp: ``Target.createTarget``; ``background=`` is a no-op (no human
237
+ focus to protect — every tab is "background").
238
+
239
+ The ``targetId`` stays valid across heredocs as long as the tab is open
240
+ and the daemon is alive — print it, capture it in the agent's
241
+ conversation, and use ``switch_tab(targetId)`` in later heredocs to
242
+ re-bind. See SKILL.md "Persisting a tab handle across heredocs".
243
+ """
244
+ sess = current_session()
245
+ _, sid = _session_name_and_id(sess) # name is the daemon's concern (group title)
246
+ try:
247
+ payload = sess.cdp.send(
248
+ "BrowserwrightDaemon.openBackgroundTab",
249
+ url=url, bsSession=sid, background=background,
250
+ )
251
+ except CDPError as e:
252
+ raise CDPError(
253
+ method="BrowserwrightDaemon.openBackgroundTab",
254
+ params={"url": url},
255
+ cdp_message=(
256
+ f"open failed: {e.cdp_message}. Requires a running daemon."
257
+ ),
258
+ ) from e
259
+ if not payload:
260
+ raise CDPError(
261
+ method="BrowserwrightDaemon.openBackgroundTab",
262
+ params={"url": url},
263
+ cdp_message="daemon returned an empty payload",
264
+ )
265
+ target_id = payload.get("targetId")
266
+ session_id = payload.get("sessionId")
267
+ if not target_id or not session_id:
268
+ raise CDPError(
269
+ method="BrowserwrightDaemon.openBackgroundTab",
270
+ params={"url": url},
271
+ cdp_message=f"daemon returned incomplete payload: {payload!r}",
272
+ )
273
+ from ..session_runtime import persist_target, register_recovered
274
+ register_recovered(sess, payload)
275
+ persist_target(target_id, group_id=payload.get("groupId"), sess=sess)
276
+ return {
277
+ "targetId": target_id,
278
+ "tabId": payload.get("tabId"),
279
+ "url": payload.get("url", url),
280
+ "title": payload.get("title", ""),
281
+ # groupId is the session's tab-group id on extension (the durable
282
+ # reconnect anchor), -1 on rdp (tab groups are an extension concept).
283
+ "groupId": payload.get("groupId", -1),
284
+ }
285
+
286
+
287
+ def new_tab(url: str = "about:blank") -> dict:
288
+ """DEPRECATED alias for :func:`open`. Kept for one release so existing
289
+ callers / solidified tasks don't break. Use ``open(url)`` instead."""
290
+ return open(url)
291
+
292
+
293
+ def _wait_for_real_load(target_url: str, *, timeout: float) -> bool:
294
+ """Wait until the page has actually navigated AND finished loading.
295
+
296
+ This is the two-condition wait we need after ``Target.createTarget`` —
297
+ plain ``wait_for_load`` only checks ``document.readyState`` which is
298
+ "complete" on the empty placeholder before the URL kicks in.
299
+ """
300
+ from .interact import js
301
+
302
+ deadline = time.monotonic() + timeout
303
+ moved = False
304
+ while time.monotonic() < deadline:
305
+ try:
306
+ state = js(
307
+ "return {h: location.href, r: document.readyState}"
308
+ )
309
+ except Exception:
310
+ state = None
311
+ if state:
312
+ href = state.get("h") or ""
313
+ ready = state.get("r")
314
+ if not moved and href and href != "about:blank":
315
+ moved = True
316
+ if moved and ready == "complete":
317
+ return True
318
+ time.sleep(0.2)
319
+ return False
320
+
321
+
322
+ def goto_url(url: str) -> dict:
323
+ """Navigate the currently attached tab to ``url``. If no tab is attached
324
+ yet, attach the first real page (or create one)."""
325
+ sess = current_session()
326
+ if not sess.current_target_id:
327
+ # Attach to first non-chrome page, or open a new one.
328
+ tabs = [t for t in list_tabs(include_chrome=False) if t["url"] != ""]
329
+ if tabs:
330
+ switch_tab(tabs[0])
331
+ else:
332
+ return new_tab(url)
333
+ sid = sess.cdp.attach(sess.current_target_id)
334
+ try:
335
+ sess.cdp.send("Page.navigate", session=sid, url=url)
336
+ except CDPError as e:
337
+ raise PageLoadFailed(url=url, reason=e.cdp_message) from e
338
+ return {"url": url}
339
+
340
+
341
+ def reload(*, hard: bool = False) -> dict:
342
+ """Reload the currently attached tab, then wait for it to finish loading.
343
+
344
+ First-class refresh primitive: issues ``Page.reload`` (``ignoreCache=hard``
345
+ bypasses the HTTP cache, the equivalent of Ctrl/Cmd-Shift-R) and blocks on
346
+ ``wait_for_load()``. Use this instead of ``goto_url(current_url)`` to get the
347
+ page to re-fetch — it's what you reach for when a tab is stale or an action
348
+ didn't take. Returns the post-reload ``page_info()`` dict.
349
+
350
+ Requires a tab to be attached; auto-attaches via ``current_page()`` if the
351
+ session has no current target yet.
352
+ """
353
+ from .inspect import cdp, page_info
354
+
355
+ sess = current_session()
356
+ if not sess.current_target_id:
357
+ current_page()
358
+ sid = sess.cdp.attach(sess.current_target_id)
359
+ try:
360
+ cdp("Page.reload", session_id=sid, ignoreCache=hard)
361
+ except CDPError as e:
362
+ raise PageLoadFailed(url="(reload)", reason=e.cdp_message) from e
363
+ wait_for_load()
364
+ return page_info()
365
+
366
+
367
+ def current_page() -> dict:
368
+ """The session's current working tab; auto-opens one if none (docs §Tier B).
369
+
370
+ Unified across backends — no ``backend_name`` branch. Resolution order:
371
+ 1. the cached current target, if it's still a live tab of this session;
372
+ 2. transparent reconnect-recovery (ledger.runtime → group id);
373
+ 3. an existing tab of the session (first real page);
374
+ 4. else ``open()`` a fresh working tab.
375
+
376
+ The empty fallback is ``open()`` (a NEW tab), NOT ``attach_active()`` —
377
+ adopt moves the user's focused tab, too invasive for an implicit call
378
+ (docs: "current_page() empty fallback = open() NOT adopt").
379
+ """
380
+ sess = current_session()
381
+ # 1. Cached current target still valid?
382
+ if sess.current_target_id:
383
+ for t in list_tabs(include_chrome=False):
384
+ if t["targetId"] == sess.current_target_id:
385
+ return {**t, "accuracy": "exact"}
386
+ # Cached target gone (tab closed). Drop it and fall through.
387
+ sess.current_target_id = None
388
+ # 2. Transparent reconnect-recovery before opening anything new.
389
+ from ..session_runtime import ensure_session_target
390
+ recovered = ensure_session_target(sess)
391
+ if recovered:
392
+ for t in list_tabs(include_chrome=False):
393
+ if t["targetId"] == recovered:
394
+ return {**t, "accuracy": "exact"}
395
+ return {"targetId": recovered, "accuracy": "exact"}
396
+ # 3. Any existing real tab of the session.
397
+ tabs = list_tabs(include_chrome=False)
398
+ if tabs:
399
+ switch_tab(tabs[0])
400
+ return {"targetId": tabs[0]["targetId"], "url": tabs[0]["url"],
401
+ "title": tabs[0]["title"], "accuracy": "unknown"}
402
+ # 4. Empty session — open a fresh working tab (NOT adopt).
403
+ return open("about:blank") | {"accuracy": "unknown"}
404
+
405
+
406
+ def wait(seconds: float = 1.0) -> None:
407
+ time.sleep(seconds)
408
+
409
+
410
+ def wait_for_load(timeout: float = 15.0) -> bool:
411
+ """Block until ``document.readyState === 'complete'`` or timeout."""
412
+ from .interact import js # local import to avoid cycle at module init
413
+
414
+ deadline = time.monotonic() + timeout
415
+ while time.monotonic() < deadline:
416
+ try:
417
+ state = js("return document.readyState")
418
+ except Exception:
419
+ state = None
420
+ if state == "complete":
421
+ return True
422
+ time.sleep(0.3)
423
+ return False
424
+
425
+
426
+ def ensure_real_tab() -> dict | None:
427
+ """Switch to a real user-facing tab if the current attachment is on a
428
+ chrome:// / devtools:// / about: / extension page.
429
+
430
+ Returns the dict of the tab we landed on, or ``None`` if no real
431
+ tabs exist. Falls back to ``current_tab()`` when the current
432
+ attachment is already a real page.
433
+
434
+ Use this when ``current_page()``'s active-tab heuristic isn't
435
+ available (e.g. the daemon's active-tab probe is unreachable) and
436
+ you just want "some real page" to operate on.
437
+ """
438
+ tabs = list_tabs(include_chrome=False)
439
+ if not tabs:
440
+ return None
441
+ cur = current_tab()
442
+ if cur and cur.get("url") and not cur["url"].startswith(_INTERNAL):
443
+ return cur
444
+ switch_tab(tabs[0])
445
+ return tabs[0]
446
+
447
+
448
+ def _session_name_and_id(sess) -> tuple[Any, Any]:
449
+ """Resolve the current session's (name, id) from the bound record.
450
+
451
+ Either may be None when no session is in scope.
452
+ """
453
+ rec = getattr(sess, "session_record", None)
454
+ if isinstance(rec, dict):
455
+ return rec.get("name"), rec.get("id")
456
+ return None, None
457
+
458
+
459
+ def open_background(url: str, *, group: str | None = None) -> dict:
460
+ """DEPRECATED alias for :func:`open` (``background=True``). Kept for one
461
+ release so existing callers / solidified tasks don't break.
462
+
463
+ The ``group=`` kwarg is now an internal daemon detail (the group is
464
+ derived from the session) — it is accepted but ignored. Use
465
+ ``open(url, background=True)`` instead.
466
+ """
467
+ return open(url, background=True)
468
+
469
+
470
+ def close_tab(
471
+ session_id: str | None = None, *, target_id: str | None = None,
472
+ ) -> dict:
473
+ """Phase B Feature 2 — close the tab via ``chrome.tabs.remove``.
474
+
475
+ Identify the tab one of two ways:
476
+ - ``target_id`` (e.g. ``ext-tab-N`` returned by ``open_background``) —
477
+ globally addressable, works across daemon-client boundaries.
478
+ - ``session_id`` — per-client; falls back to the currently-attached
479
+ tab when omitted. Only works in contexts that share the original
480
+ opener's persistent ws.
481
+
482
+ This is NOT a debugger detach. After the call returns the sessionId is
483
+ invalid; any cached CDPSession state for it is cleared.
484
+
485
+ Returns ``{"ok": True, "tabId": N}``.
486
+
487
+ Unified across backends: extension closes the tab via the relay; rdp closes
488
+ it via ``Target.closeTarget`` (daemon-side). Never requires a specific backend.
489
+ """
490
+ sess = current_session()
491
+ # Resolve target_id and session_id from local state when not passed,
492
+ # then forward to the daemon over the long-lived ws so the session
493
+ # binding lookup runs against this client's bindings.
494
+ resolved_target_id = target_id
495
+ resolved_session_id = session_id
496
+ if resolved_target_id is None and resolved_session_id is None:
497
+ resolved_target_id = sess.current_target_id
498
+ if not resolved_target_id:
499
+ raise CDPError(
500
+ method="BrowserwrightDaemon.closeTab",
501
+ params={"sessionId": None, "targetId": None},
502
+ cdp_message="close_tab: no current attached tab to close",
503
+ )
504
+ resolved_session_id = sess.cdp._sessions.get(resolved_target_id)
505
+ elif resolved_session_id is None and resolved_target_id is not None:
506
+ # Caller passed target_id; fill in session_id from local cache if we have it.
507
+ resolved_session_id = sess.cdp._sessions.get(resolved_target_id)
508
+ try:
509
+ payload = sess.cdp.send(
510
+ "BrowserwrightDaemon.closeTab",
511
+ sessionId=resolved_session_id,
512
+ targetId=resolved_target_id,
513
+ )
514
+ except CDPError as e:
515
+ raise CDPError(
516
+ method="BrowserwrightDaemon.closeTab",
517
+ params={"sessionId": resolved_session_id,
518
+ "targetId": resolved_target_id},
519
+ cdp_message=(
520
+ f"close_tab failed: {e.cdp_message}. "
521
+ "Requires the extension backend with a running daemon."
522
+ ),
523
+ ) from e
524
+ # Backfill the local session_id var for the state-cleanup block below.
525
+ session_id = resolved_session_id
526
+ if not payload:
527
+ raise CDPError(
528
+ method="BrowserwrightDaemon.closeTab",
529
+ params={"sessionId": session_id},
530
+ cdp_message="daemon returned an empty close-tab payload",
531
+ )
532
+ # Clear local CDPSession state — locate any target whose stored sessionId
533
+ # matches and drop it; clear the per-session event ring too.
534
+ cdp = sess.cdp
535
+ stale_targets = [tid for tid, sid in cdp._sessions.items()
536
+ if sid == session_id]
537
+ for tid in stale_targets:
538
+ cdp._sessions.pop(tid, None)
539
+ if sess.current_target_id == tid:
540
+ sess.current_target_id = None
541
+ cdp._events.pop(session_id, None)
542
+ return {"ok": bool(payload.get("ok", True)),
543
+ "tabId": payload.get("tabId")}
544
+
545
+
546
+ def iframe_target(url_substr: str) -> str | None:
547
+ """First iframe target whose URL contains ``url_substr``. Returns
548
+ its ``targetId`` (for ``js(..., target_id=...)``), or ``None`` if no
549
+ iframe matches. Useful when a site embeds a cross-origin form/widget
550
+ that ``js`` can't reach via the main-page context."""
551
+ sess = current_session()
552
+ res = sess.cdp.send("Target.getTargets")
553
+ for t in res.get("targetInfos", []):
554
+ if t.get("type") == "iframe" and url_substr in t.get("url", ""):
555
+ return t.get("targetId")
556
+ return None