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,449 @@
|
|
|
1
|
+
"""Lazy Playwright ``page`` / ``context`` for the inline execution namespace.
|
|
2
|
+
|
|
3
|
+
The agent writes real Playwright Python in ``browserwright -s <id> -e <code>``
|
|
4
|
+
against an injected ``page`` (and ``context``). The handle:
|
|
5
|
+
|
|
6
|
+
- **connects lazily**: nothing happens until the first attribute access on
|
|
7
|
+
``page`` / ``context``. A pure ``memory()`` / site-skill call never opens
|
|
8
|
+
a browser connection (see :class:`_LazyHandle`).
|
|
9
|
+
- **connects through the daemon facade**: it reads the facade ws URL the
|
|
10
|
+
daemon advertised (``browserwright-daemon status``'s ``facade.ws`` →
|
|
11
|
+
``_ipc.read_facade_file``) and ``chromium.connect_over_cdp`` to it. The
|
|
12
|
+
facade drives both the rdp and extension backends (see
|
|
13
|
+
``.trellis/spec/backend/playwright-cdp-facade.md``).
|
|
14
|
+
- **binds ``page`` to the session's current tab**: it resolves the session's
|
|
15
|
+
``current_target_id`` (ledger fast-path via ``ensure_session_target``) and
|
|
16
|
+
selects the Playwright ``Page`` whose CDP ``targetId`` matches it. If the
|
|
17
|
+
session has no current tab it opens one (``about:blank``) and binds it —
|
|
18
|
+
mirroring ``primitives/page.py:current_page()``'s "auto-open, NOT adopt"
|
|
19
|
+
rule. The bound target is persisted back to the ledger so the NEXT call
|
|
20
|
+
resolves the SAME tab (cross-call tab reuse — the whole point of Phase
|
|
21
|
+
C). ``context.new_page()`` is the explicit "new tab" escape hatch.
|
|
22
|
+
|
|
23
|
+
Lifecycle: the inline runner calls :meth:`PlaywrightHandle.close` in a
|
|
24
|
+
``finally`` so the Playwright connection is torn down cleanly at call end.
|
|
25
|
+
``connect_over_cdp``'s ``browser.close()`` only DISCONNECTS the CDP transport —
|
|
26
|
+
it does NOT close the user's real tabs/browser — so closing is safe. We never
|
|
27
|
+
call ``page.close()`` / ``context.close()``.
|
|
28
|
+
|
|
29
|
+
Sync API only: inline execution is a standalone process with no running asyncio
|
|
30
|
+
loop, so we use ``playwright.sync_api``. The session's daemon client
|
|
31
|
+
(``mode_b_client`` over a unix socket) is plain sockets, not asyncio — so there
|
|
32
|
+
is no loop conflict with Playwright's sync driver.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
from typing import Any
|
|
38
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
39
|
+
|
|
40
|
+
from ..errors import BrowserwrightError
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FacadeUnavailable(BrowserwrightError):
|
|
44
|
+
"""The Playwright facade ws could not be discovered/connected.
|
|
45
|
+
|
|
46
|
+
Carried fix: ensure the daemon is running (it auto-enables the facade); a
|
|
47
|
+
daemon predating Phase C, or one started with ``--facade-port 0``, won't
|
|
48
|
+
advertise one."""
|
|
49
|
+
|
|
50
|
+
default_fix = ("ensure the daemon is running and the Playwright facade is "
|
|
51
|
+
"enabled (it is on by default; `browserwright-daemon "
|
|
52
|
+
"status --json` should show a non-null `facade.ws`). Do not "
|
|
53
|
+
"pass `--facade-port 0`.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _current_browserwright_session_id() -> str | None:
|
|
57
|
+
"""Best-effort Browserwright session id bound to the current process."""
|
|
58
|
+
try:
|
|
59
|
+
from ..session import current_session
|
|
60
|
+
rec = getattr(current_session(), "session_record", None)
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
if isinstance(rec, dict) and rec.get("id"):
|
|
64
|
+
return str(rec["id"])
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _with_session_query(ws_url: str, session_id: str | None) -> str:
|
|
69
|
+
"""Append the Browserwright session id to the facade URL query."""
|
|
70
|
+
return _session_scoped_ws_url(ws_url, session_id)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _facade_ws_url(*, session_id: str | None = None) -> str:
|
|
74
|
+
"""Discover the running daemon's facade ws URL.
|
|
75
|
+
|
|
76
|
+
Prefers an explicit ``BD_FACADE_WS`` override (tests / advanced setups),
|
|
77
|
+
else reads the daemon's ``_ipc`` facade discovery file. Raises
|
|
78
|
+
:class:`FacadeUnavailable` when nothing is found."""
|
|
79
|
+
override = os.environ.get("BD_FACADE_WS")
|
|
80
|
+
if session_id is None:
|
|
81
|
+
session_id = _current_browserwright_session_id()
|
|
82
|
+
if override:
|
|
83
|
+
return _with_session_query(override, session_id)
|
|
84
|
+
from ..daemon import _ipc
|
|
85
|
+
ws, _port = _ipc.read_facade_file()
|
|
86
|
+
if not ws:
|
|
87
|
+
raise FacadeUnavailable(
|
|
88
|
+
"no Playwright facade advertised by the daemon "
|
|
89
|
+
"(facade discovery file absent)")
|
|
90
|
+
return _with_session_query(ws, session_id)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _session_scoped_ws_url(ws_url: str, session_id: str | None) -> str:
|
|
94
|
+
"""Attach the browserwright session id to a facade ws URL."""
|
|
95
|
+
if not session_id:
|
|
96
|
+
return ws_url
|
|
97
|
+
parts = urlsplit(ws_url)
|
|
98
|
+
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
|
99
|
+
query["session"] = session_id
|
|
100
|
+
return urlunsplit((
|
|
101
|
+
parts.scheme, parts.netloc, parts.path,
|
|
102
|
+
urlencode(query), parts.fragment,
|
|
103
|
+
))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _session_id_from(sess: Any) -> str | None:
|
|
107
|
+
rec = getattr(sess, "session_record", None)
|
|
108
|
+
sid = rec.get("id") if isinstance(rec, dict) else None
|
|
109
|
+
return sid if isinstance(sid, str) and sid else None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _agent_page_targets(sess: Any) -> list[dict]:
|
|
113
|
+
"""All page-type targets `{targetId, url}` of the session, via the AGENT
|
|
114
|
+
CDP path (`sess.cdp` → daemon `Target.getTargets`).
|
|
115
|
+
|
|
116
|
+
Why the agent path and not a Playwright CDP session? Two Playwright CDP
|
|
117
|
+
sessions for target enumeration are FATAL over the extension facade:
|
|
118
|
+
`context.new_cdp_session(page)` collides with the page's primary session,
|
|
119
|
+
and even `browser.new_browser_cdp_session()` reuses the facade's single
|
|
120
|
+
synthetic browser sessionId — both trip a Playwright-driver assert that
|
|
121
|
+
kills the connection. The agent path is the daemon's own, fully-tested
|
|
122
|
+
channel; its targetIds are exactly the daemon/ledger ids (extension
|
|
123
|
+
`ext-tab-N`, rdp real ids), so they line up with the ledger's
|
|
124
|
+
`current_target_id` and with `connect_over_cdp`'s synthesized targetIds."""
|
|
125
|
+
try:
|
|
126
|
+
res = sess.cdp.send("Target.getTargets")
|
|
127
|
+
except Exception:
|
|
128
|
+
return []
|
|
129
|
+
out: list[dict] = []
|
|
130
|
+
for ti in (res.get("targetInfos") or []):
|
|
131
|
+
if ti.get("type") != "page":
|
|
132
|
+
continue
|
|
133
|
+
tid = ti.get("targetId")
|
|
134
|
+
if isinstance(tid, str):
|
|
135
|
+
out.append({"targetId": tid, "url": ti.get("url", "")})
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---- reusable connect + bind (shared by PlaywrightHandle and the executor) --
|
|
140
|
+
#
|
|
141
|
+
# Phase B: the persistent per-session executor (``browserwright._executor``)
|
|
142
|
+
# runs the SAME connect+bind dance, just ONCE at cold-start instead of per
|
|
143
|
+
# heredoc. These free functions are the single source of truth so the executor
|
|
144
|
+
# never re-implements (and never drifts from) the FATAL "no Playwright CDP
|
|
145
|
+
# session over the extension facade" constraint. ``PlaywrightHandle`` below is
|
|
146
|
+
# the per-heredoc Phase C consumer; the executor is the Phase B consumer.
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def connect_over_cdp(pw: Any, *, session_id: str | None = None,
|
|
150
|
+
attempts: int = 1,
|
|
151
|
+
backoff_s: float = 0.5) -> Any:
|
|
152
|
+
"""``chromium.connect_over_cdp`` to the daemon facade. Returns the Browser.
|
|
153
|
+
|
|
154
|
+
Raises :class:`FacadeUnavailable` when the facade ws can't be discovered or
|
|
155
|
+
the connect fails — the actionable error the agent should see.
|
|
156
|
+
|
|
157
|
+
``attempts`` / ``backoff_s`` (defense-in-depth for the Phase B executor
|
|
158
|
+
cold-start, Failure #4): a freshly-restarted daemon launches the rdp Chrome
|
|
159
|
+
lazily, so the executor can race a Chrome that is still binding its CDP port
|
|
160
|
+
— the facade then 404s/403s for a brief window. Retrying the connect a few
|
|
161
|
+
times over a few seconds absorbs that startup race. The per-heredoc Phase C
|
|
162
|
+
consumer keeps ``attempts=1`` (the daemon is already warm there); only the
|
|
163
|
+
executor cold-start passes a higher count. Discovery (`_facade_ws_url`) is
|
|
164
|
+
re-read each attempt so a freshly-(re)written facade file is picked up."""
|
|
165
|
+
attempts = max(1, attempts)
|
|
166
|
+
last_exc: Exception | None = None
|
|
167
|
+
for i in range(attempts):
|
|
168
|
+
try:
|
|
169
|
+
ws_url = _facade_ws_url()
|
|
170
|
+
except FacadeUnavailable as e:
|
|
171
|
+
last_exc = e
|
|
172
|
+
ws_url = None
|
|
173
|
+
if ws_url is not None:
|
|
174
|
+
try:
|
|
175
|
+
ws_url = _session_scoped_ws_url(ws_url, session_id)
|
|
176
|
+
return pw.chromium.connect_over_cdp(ws_url, timeout=20000)
|
|
177
|
+
except Exception as e: # noqa: BLE001
|
|
178
|
+
last_exc = FacadeUnavailable(
|
|
179
|
+
f"connect_over_cdp({ws_url!r}) failed: {e}")
|
|
180
|
+
if i < attempts - 1:
|
|
181
|
+
import time as _time
|
|
182
|
+
_time.sleep(backoff_s)
|
|
183
|
+
if isinstance(last_exc, FacadeUnavailable):
|
|
184
|
+
raise last_exc
|
|
185
|
+
raise FacadeUnavailable(
|
|
186
|
+
"connect_over_cdp failed: facade unavailable after "
|
|
187
|
+
f"{attempts} attempt(s)")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def context_for_browser(browser: Any) -> Any:
|
|
191
|
+
"""The first existing BrowserContext, or a fresh one."""
|
|
192
|
+
context = browser.contexts[0] if browser.contexts else browser.new_context()
|
|
193
|
+
from ._smart_goto import patch_context_pages
|
|
194
|
+
patch_context_pages(context)
|
|
195
|
+
return context
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def bind_current_page(context: Any, sess: Any) -> Any:
|
|
199
|
+
"""Bind the Playwright ``Page`` to the session's current tab.
|
|
200
|
+
|
|
201
|
+
The tab itself is resolved/created via the AGENT primitive
|
|
202
|
+
``current_page()`` — NOT ``context.new_page()``. This is deliberate:
|
|
203
|
+
|
|
204
|
+
- ``current_page()`` owns the reuse/recovery/auto-open discipline
|
|
205
|
+
(reuse the ledger target, recover via the tab group, else open a
|
|
206
|
+
fresh tab in THIS session's group, NOT adopt) and PERSISTS the
|
|
207
|
+
chosen target to the ledger, so the next cold-start resolves the same
|
|
208
|
+
tab — the cross-call reuse acceptance.
|
|
209
|
+
- It also creates the tab inside the session's tab group (extension
|
|
210
|
+
backend), keeping the agent ledger and the Playwright view on ONE
|
|
211
|
+
tab. ``context.new_page()`` over the facade would open an un-grouped
|
|
212
|
+
tab the agent path can't track → ledger drift → tab explosion.
|
|
213
|
+
|
|
214
|
+
We then attach Playwright to that exact tab by matching the agent's
|
|
215
|
+
targetId against ``context.pages`` (the facade replays ``attached`` events
|
|
216
|
+
for every open tab, so a session-group tab IS enumerable). Mapping is done
|
|
217
|
+
WITHOUT any Playwright CDP session — a per-page (``context.new_cdp_session``)
|
|
218
|
+
or even a second browser-level (``new_browser_cdp_session``) session is
|
|
219
|
+
FATAL over the extension facade (the facade reuses one synthetic sessionId
|
|
220
|
+
→ a Playwright-driver assert kills the connection). We correlate by URL via
|
|
221
|
+
the AGENT path instead.
|
|
222
|
+
"""
|
|
223
|
+
from ..primitives.page import current_page
|
|
224
|
+
from ._smart_goto import patch_context_pages, patch_page_goto
|
|
225
|
+
|
|
226
|
+
patch_context_pages(context)
|
|
227
|
+
|
|
228
|
+
# Resolve/create + persist the session's current tab via the agent path.
|
|
229
|
+
info = current_page()
|
|
230
|
+
target_id = info.get("targetId") if isinstance(info, dict) else None
|
|
231
|
+
|
|
232
|
+
if target_id:
|
|
233
|
+
page = page_for_target(context, sess, target_id, info.get("url"))
|
|
234
|
+
if page is not None:
|
|
235
|
+
return patch_page_goto(page)
|
|
236
|
+
if _wait_for_session_announce(sess, timeout=2.0):
|
|
237
|
+
page = page_for_target(context, sess, target_id, info.get("url"))
|
|
238
|
+
if page is not None:
|
|
239
|
+
return patch_page_goto(page)
|
|
240
|
+
|
|
241
|
+
# Could not correlate a Playwright Page to the agent tab (e.g. the facade
|
|
242
|
+
# hasn't replayed it yet). Fall back to a Playwright-created page so the
|
|
243
|
+
# agent still gets a usable handle; the agent ledger already points at the
|
|
244
|
+
# current tab for the next cold-start.
|
|
245
|
+
if context.pages:
|
|
246
|
+
return patch_page_goto(context.pages[0])
|
|
247
|
+
return patch_page_goto(context.new_page())
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _wait_for_session_announce(sess: Any, *, timeout: float) -> bool:
|
|
251
|
+
"""Wait for the daemon facade to announce the agent-created tab.
|
|
252
|
+
|
|
253
|
+
This is a daemon RPC because the Playwright binding code runs in the
|
|
254
|
+
skill/executor process, while the announce event is produced inside the
|
|
255
|
+
daemon's extension facade bridge.
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
rec = getattr(sess, "session_record", None)
|
|
259
|
+
sid = rec.get("id") if isinstance(rec, dict) else None
|
|
260
|
+
if not sid:
|
|
261
|
+
return False
|
|
262
|
+
res = sess.cdp.send(
|
|
263
|
+
"BrowserwrightDaemon.waitForSessionAnnounce",
|
|
264
|
+
bsSession=sid,
|
|
265
|
+
timeout=timeout,
|
|
266
|
+
)
|
|
267
|
+
return bool(res.get("announced"))
|
|
268
|
+
except Exception:
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def page_for_target(context: Any, sess: Any, target_id: str,
|
|
273
|
+
hint_url: str | None = None) -> Any | None:
|
|
274
|
+
"""Find the live Playwright Page for the session's ``target_id``.
|
|
275
|
+
|
|
276
|
+
Mapping uses NO Playwright CDP session (fatal over the extension facade —
|
|
277
|
+
see ``_agent_page_targets``). Steady state — the session owns exactly one
|
|
278
|
+
tab — binds that page directly (one tab per session is the whole point of
|
|
279
|
+
the reuse discipline, and also resolves the ``about:blank`` ambiguity URLs
|
|
280
|
+
can't). Otherwise correlate the target's URL (agent-path
|
|
281
|
+
``Target.getTargets``, or the caller's hint) to the matching page."""
|
|
282
|
+
pages = list(context.pages)
|
|
283
|
+
if not pages:
|
|
284
|
+
return None
|
|
285
|
+
if len(pages) == 1:
|
|
286
|
+
return pages[0]
|
|
287
|
+
url = hint_url
|
|
288
|
+
if url is None:
|
|
289
|
+
targets = _agent_page_targets(sess)
|
|
290
|
+
url = next((t["url"] for t in targets
|
|
291
|
+
if t["targetId"] == target_id), None)
|
|
292
|
+
if not url:
|
|
293
|
+
return None
|
|
294
|
+
matches = [p for p in pages if p.url == url]
|
|
295
|
+
if not matches:
|
|
296
|
+
return None
|
|
297
|
+
if len(matches) == 1:
|
|
298
|
+
return matches[0]
|
|
299
|
+
# Ambiguous (e.g. several `about:blank` tabs incl. a non-session one):
|
|
300
|
+
# prefer the MOST-RECENTLY-announced match. The facade replays targets in
|
|
301
|
+
# creation order, so the session's just-opened tab is announced last —
|
|
302
|
+
# `context.pages` preserves that order. This disambiguates the fresh-blank
|
|
303
|
+
# first bind; once the agent tab carries real content its url is unique and
|
|
304
|
+
# the tie never arises.
|
|
305
|
+
return matches[-1]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class PlaywrightHandle:
|
|
309
|
+
"""Owns the lazy Playwright connection + the bound ``page`` / ``context``.
|
|
310
|
+
|
|
311
|
+
Construct one per heredoc; access ``.page`` / ``.context`` to trigger the
|
|
312
|
+
lazy connect+bind; call ``.close()`` in a ``finally`` to tear down.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def __init__(self) -> None:
|
|
316
|
+
self._pw: Any = None # sync_playwright() context manager
|
|
317
|
+
self._pw_cm: Any = None # the entered manager (for __exit__)
|
|
318
|
+
self._browser: Any = None # connect_over_cdp Browser
|
|
319
|
+
self._context: Any = None # bound BrowserContext
|
|
320
|
+
self._page: Any = None # bound Page
|
|
321
|
+
self._connected = False
|
|
322
|
+
|
|
323
|
+
# ---- lazy connect + bind --------------------------------------------
|
|
324
|
+
|
|
325
|
+
def _ensure_connected(self) -> None:
|
|
326
|
+
if self._connected:
|
|
327
|
+
return
|
|
328
|
+
try:
|
|
329
|
+
from playwright.sync_api import sync_playwright
|
|
330
|
+
except ImportError as e: # pragma: no cover - dep is a hard requirement
|
|
331
|
+
raise FacadeUnavailable(
|
|
332
|
+
"playwright is not importable; it is a runtime dependency of "
|
|
333
|
+
"the skill heredoc `page`/`context` surface") from e
|
|
334
|
+
|
|
335
|
+
# Enter sync_playwright() and connect_over_cdp. Keep the context manager
|
|
336
|
+
# so close() can __exit__ it (stops the bundled driver process). The
|
|
337
|
+
# connect + bind logic is the shared free functions (also used by the
|
|
338
|
+
# Phase B executor), so the FATAL "no Playwright CDP session over the
|
|
339
|
+
# extension facade" constraint lives in exactly one place.
|
|
340
|
+
from ..session import current_session
|
|
341
|
+
|
|
342
|
+
self._pw_cm = sync_playwright()
|
|
343
|
+
self._pw = self._pw_cm.__enter__()
|
|
344
|
+
sess = current_session()
|
|
345
|
+
try:
|
|
346
|
+
self._browser = connect_over_cdp(
|
|
347
|
+
self._pw, session_id=_session_id_from(sess))
|
|
348
|
+
except Exception as e:
|
|
349
|
+
# Tear the driver back down so a failed connect doesn't leak it.
|
|
350
|
+
with _suppress():
|
|
351
|
+
self._pw_cm.__exit__(type(e), e, e.__traceback__)
|
|
352
|
+
self._pw_cm = None
|
|
353
|
+
self._pw = None
|
|
354
|
+
raise
|
|
355
|
+
self._context = context_for_browser(self._browser)
|
|
356
|
+
self._page = bind_current_page(self._context, sess)
|
|
357
|
+
self._connected = True
|
|
358
|
+
|
|
359
|
+
# ---- accessors (trigger the lazy connect) ---------------------------
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def page(self) -> Any:
|
|
363
|
+
self._ensure_connected()
|
|
364
|
+
return self._page
|
|
365
|
+
|
|
366
|
+
@property
|
|
367
|
+
def context(self) -> Any:
|
|
368
|
+
self._ensure_connected()
|
|
369
|
+
return self._context
|
|
370
|
+
|
|
371
|
+
# ---- teardown -------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
def close(self) -> None:
|
|
374
|
+
"""Disconnect the Playwright connection WITHOUT closing the user's real
|
|
375
|
+
tabs/browser.
|
|
376
|
+
|
|
377
|
+
We deliberately do NOT call ``browser.close()`` /
|
|
378
|
+
``context.close()`` / ``page.close()``: over the daemon facade (esp. the
|
|
379
|
+
extension backend, where teardown CDP frames aren't all answered)
|
|
380
|
+
``browser.close()`` round-trips a ``Browser.close`` that can hang the
|
|
381
|
+
Playwright driver — and ``context``/``page`` close WOULD close the
|
|
382
|
+
user's real tabs. Instead we just ``__exit__`` the ``sync_playwright()``
|
|
383
|
+
manager, which stops the bundled driver subprocess and severs the CDP
|
|
384
|
+
transport (a pure disconnect — the user's tabs stay open). Idempotent +
|
|
385
|
+
fully suppressed: teardown of a partly-connected handle (e.g. connect
|
|
386
|
+
failed) must never raise."""
|
|
387
|
+
if not self._connected and self._pw_cm is None:
|
|
388
|
+
return
|
|
389
|
+
self._browser = None
|
|
390
|
+
self._context = None
|
|
391
|
+
self._page = None
|
|
392
|
+
if self._pw_cm is not None:
|
|
393
|
+
with _suppress():
|
|
394
|
+
# Stops the driver process → disconnects the CDP transport.
|
|
395
|
+
# Does NOT close the user's tabs/browser.
|
|
396
|
+
self._pw_cm.__exit__(None, None, None)
|
|
397
|
+
self._pw_cm = None
|
|
398
|
+
self._pw = None
|
|
399
|
+
self._connected = False
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class _suppress:
|
|
403
|
+
"""Tiny ``contextlib.suppress(Exception)`` clone kept local so teardown has
|
|
404
|
+
no import surface to fail on."""
|
|
405
|
+
|
|
406
|
+
def __enter__(self) -> "_suppress":
|
|
407
|
+
return self
|
|
408
|
+
|
|
409
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
410
|
+
return exc_type is not None and issubclass(exc_type, Exception)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class _LazyHandleProxy:
|
|
414
|
+
"""A transparent proxy that defers ALL access to the underlying live object
|
|
415
|
+
(``handle.page`` / ``handle.context``) until first use.
|
|
416
|
+
|
|
417
|
+
This is what lands in the heredoc namespace as ``page`` / ``context``: a
|
|
418
|
+
pure ``memory()`` heredoc that never touches them never triggers the
|
|
419
|
+
connect. Any attribute access / call / item access / iteration forwards to
|
|
420
|
+
the real object, which lazily connects on first resolution."""
|
|
421
|
+
|
|
422
|
+
__slots__ = ("_handle", "_attr")
|
|
423
|
+
|
|
424
|
+
def __init__(self, handle: PlaywrightHandle, attr: str) -> None:
|
|
425
|
+
object.__setattr__(self, "_handle", handle)
|
|
426
|
+
object.__setattr__(self, "_attr", attr)
|
|
427
|
+
|
|
428
|
+
def _resolve(self) -> Any:
|
|
429
|
+
return getattr(object.__getattribute__(self, "_handle"),
|
|
430
|
+
object.__getattribute__(self, "_attr"))
|
|
431
|
+
|
|
432
|
+
def __getattr__(self, name: str) -> Any:
|
|
433
|
+
return getattr(self._resolve(), name)
|
|
434
|
+
|
|
435
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
436
|
+
setattr(self._resolve(), name, value)
|
|
437
|
+
|
|
438
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
439
|
+
return self._resolve()(*args, **kwargs)
|
|
440
|
+
|
|
441
|
+
def __getitem__(self, key: Any) -> Any:
|
|
442
|
+
return self._resolve()[key]
|
|
443
|
+
|
|
444
|
+
def __iter__(self) -> Any:
|
|
445
|
+
return iter(self._resolve())
|
|
446
|
+
|
|
447
|
+
def __repr__(self) -> str:
|
|
448
|
+
return f"<lazy {object.__getattribute__(self, '_attr')} (Playwright, " \
|
|
449
|
+
f"connects on first use)>"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Phase C PR2: ``snapshot()`` — Playwright first-party AI aria snapshot.
|
|
2
|
+
|
|
3
|
+
The agent calls ``snapshot()`` in a heredoc to observe what is on the current
|
|
4
|
+
``page`` and to obtain **stable refs** it can act on, instead of taking a
|
|
5
|
+
screenshot or inventing CSS selectors. This is the SAME first-party snapshot
|
|
6
|
+
``@playwright/mcp`` uses: ``page.aria_snapshot(mode="ai")`` (Playwright 1.60
|
|
7
|
+
Python sync), which renders an accessibility tree where every node carries a
|
|
8
|
+
``[ref=eN]`` token.
|
|
9
|
+
|
|
10
|
+
Ref → locator contract
|
|
11
|
+
----------------------
|
|
12
|
+
Each ``[ref=eN]`` in the output resolves to a live element via Playwright's
|
|
13
|
+
``aria-ref=`` selector engine, scoped to the LAST snapshot taken on that page::
|
|
14
|
+
|
|
15
|
+
snapshot() # observe
|
|
16
|
+
page.locator("aria-ref=e3").click() # act on the e3 node
|
|
17
|
+
page.locator("aria-ref=e5").fill("hello") # act on the e5 node
|
|
18
|
+
|
|
19
|
+
The ref store lives on the page and is refreshed on each ``aria_snapshot``
|
|
20
|
+
call, so always re-``snapshot()`` after an action (observe → act → observe)
|
|
21
|
+
before reusing refs from a stale snapshot.
|
|
22
|
+
|
|
23
|
+
Why first-party (not a ported custom snapshot)
|
|
24
|
+
----------------------------------------------
|
|
25
|
+
Verified against Playwright 1.60 Python sync: ``Page.aria_snapshot(mode="ai")``
|
|
26
|
+
yields ``[ref=eN]`` refs and ``page.locator("aria-ref=eN")`` round-trips to a
|
|
27
|
+
clickable/fillable locator. There is no need to port playwriter's custom
|
|
28
|
+
aria-snapshot — the first-party AI mode is cleanly available here.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from .playwright_handle import PlaywrightHandle
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def make_snapshot(handle: PlaywrightHandle):
|
|
36
|
+
"""Build the per-heredoc ``snapshot()`` bound to this heredoc's lazy
|
|
37
|
+
Playwright handle. Injected into the exec namespace by ``build_globals``.
|
|
38
|
+
|
|
39
|
+
Triggering ``snapshot()`` resolves ``handle.page`` (lazily connecting the
|
|
40
|
+
facade on first use, exactly like ``page``/``context``)."""
|
|
41
|
+
|
|
42
|
+
def snapshot(*, interactive_only: bool = True,
|
|
43
|
+
max_chars: int | None = 6000) -> str:
|
|
44
|
+
"""Observe the current ``page`` as a first-party Playwright AI aria
|
|
45
|
+
snapshot. Returns a compact accessibility tree where each node carries
|
|
46
|
+
a ``[ref=eN]`` ref.
|
|
47
|
+
|
|
48
|
+
Act on a ref via Playwright's ``aria-ref=`` selector engine on the SAME
|
|
49
|
+
page (the ref store is refreshed by this call)::
|
|
50
|
+
|
|
51
|
+
snapshot()
|
|
52
|
+
page.locator("aria-ref=e3").click()
|
|
53
|
+
page.locator("aria-ref=e5").fill("query")
|
|
54
|
+
|
|
55
|
+
Re-``snapshot()`` after every action: refs are scoped to the most
|
|
56
|
+
recent snapshot on the page, so a ref from a stale snapshot may no
|
|
57
|
+
longer resolve.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
interactive_only: when True (default), drop purely structural /
|
|
61
|
+
decorative nodes that carry neither a ref nor an accessible name —
|
|
62
|
+
keeps the output token-frugal and interaction-oriented. Set False
|
|
63
|
+
for the full accessibility tree (headings, text, structure).
|
|
64
|
+
max_chars: hard cap on the returned string (bounds token cost); the
|
|
65
|
+
tail is replaced with a ``… [truncated]`` marker when exceeded.
|
|
66
|
+
Pass None to disable the cap.
|
|
67
|
+
|
|
68
|
+
Returns the snapshot as a string (one node per line, indented to show
|
|
69
|
+
tree structure). Empty-page result is the bare root line.
|
|
70
|
+
"""
|
|
71
|
+
page = handle.page
|
|
72
|
+
snap = page.aria_snapshot(mode="ai")
|
|
73
|
+
if interactive_only:
|
|
74
|
+
snap = _filter_interactive(snap)
|
|
75
|
+
if max_chars is not None and len(snap) > max_chars:
|
|
76
|
+
snap = _truncate_lines(snap, max_chars)
|
|
77
|
+
return snap
|
|
78
|
+
|
|
79
|
+
return snapshot
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_TRUNC_MARKER = "… [truncated]"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _truncate_lines(snap: str, max_chars: int) -> str:
|
|
86
|
+
"""Cap ``snap`` at ``max_chars`` on a LINE boundary, never mid-line.
|
|
87
|
+
|
|
88
|
+
Splitting on a raw byte offset can sever a ``[ref=eN]`` token — leaving a
|
|
89
|
+
corrupt partial ref (``[ref=e1``) the agent might try to act on. So we drop
|
|
90
|
+
whole lines from the tail until the kept body plus the ``… [truncated]``
|
|
91
|
+
marker fits the budget. Every line that survives is therefore intact,
|
|
92
|
+
including any ref it carries. If even the first line overflows the budget we
|
|
93
|
+
still emit it whole (a ref is only useful intact) followed by the marker.
|
|
94
|
+
"""
|
|
95
|
+
lines = snap.splitlines()
|
|
96
|
+
kept: list[str] = []
|
|
97
|
+
used = 0
|
|
98
|
+
marker_cost = len(_TRUNC_MARKER) + 1 # + the "\n" before the marker
|
|
99
|
+
for ln in lines:
|
|
100
|
+
# +1 for the newline joining this line to the previous body.
|
|
101
|
+
add = len(ln) + (1 if kept else 0)
|
|
102
|
+
if used + add + marker_cost > max_chars:
|
|
103
|
+
break
|
|
104
|
+
kept.append(ln)
|
|
105
|
+
used += add
|
|
106
|
+
if not kept:
|
|
107
|
+
# Budget too small for even one line + marker: still surface line one
|
|
108
|
+
# whole — a partial ref is worse than overflowing a soft cap.
|
|
109
|
+
kept = lines[:1]
|
|
110
|
+
return "\n".join(kept) + "\n" + _TRUNC_MARKER
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _filter_interactive(snap: str) -> str:
|
|
114
|
+
"""Drop noise lines from an AI aria snapshot while preserving tree shape.
|
|
115
|
+
|
|
116
|
+
Playwright's AI snapshot tags every node it considers actionable/named with
|
|
117
|
+
a ``[ref=eN]``. We keep:
|
|
118
|
+
- any line carrying a ``[ref=`` (an addressable node), and
|
|
119
|
+
- the structural ancestor lines needed to keep indentation readable
|
|
120
|
+
(a kept line's parents).
|
|
121
|
+
|
|
122
|
+
A line without a ref (pure ``- generic`` wrappers, ``- text`` leaves) is
|
|
123
|
+
dropped UNLESS it is an ancestor of a kept (ref-carrying) line. This keeps
|
|
124
|
+
the output interaction-oriented and token-frugal without breaking the tree.
|
|
125
|
+
"""
|
|
126
|
+
lines = snap.splitlines()
|
|
127
|
+
if not lines:
|
|
128
|
+
return snap
|
|
129
|
+
|
|
130
|
+
def indent(s: str) -> int:
|
|
131
|
+
return len(s) - len(s.lstrip(" "))
|
|
132
|
+
|
|
133
|
+
# First pass: mark lines we want to keep outright (carry a ref).
|
|
134
|
+
keep = [("[ref=" in ln) for ln in lines]
|
|
135
|
+
|
|
136
|
+
# Second pass: keep ancestors of any kept line so indentation stays valid.
|
|
137
|
+
# Walk bottom-up tracking the indent of the shallowest still-needed child.
|
|
138
|
+
needed_indent: int | None = None
|
|
139
|
+
for i in range(len(lines) - 1, -1, -1):
|
|
140
|
+
ind = indent(lines[i])
|
|
141
|
+
if keep[i]:
|
|
142
|
+
# This line is kept; its parents (strictly shallower) are needed.
|
|
143
|
+
needed_indent = ind
|
|
144
|
+
continue
|
|
145
|
+
if needed_indent is not None and ind < needed_indent:
|
|
146
|
+
keep[i] = True
|
|
147
|
+
needed_indent = ind
|
|
148
|
+
|
|
149
|
+
out = [ln for ln, k in zip(lines, keep) if k]
|
|
150
|
+
return "\n".join(out) if out else snap
|