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