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,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
|