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,106 @@
1
+ """Assemble the exec globals shared by inline / repl-server / task entry points.
2
+
3
+ Every entry point hot-imports ``browserwright`` so the agent always sees the
4
+ same names (``http_get``, ``remember``, etc.). It also injects the per-heredoc
5
+ Playwright surface (``page`` / ``context`` / ``snapshot``). We also pull
6
+ ``json``, ``re``, ``time``, and a handful of builtins that agents reach for
7
+ constantly — saves a ``import`` line per heredoc.
8
+
9
+ Finally we hot-load ``$BS_HOME/agent_helpers.py`` — the agent-editable
10
+ primitive layer (see SKILL.md "Extending the primitive surface"). It loads
11
+ *after* the core surface so helpers can call core primitives, and a conflict
12
+ guard refuses any helper that would shadow a core name.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import importlib.util
17
+ import json
18
+ import re
19
+ import sys
20
+ import time
21
+ from typing import Any
22
+
23
+ import browserwright
24
+
25
+
26
+ def _load_agent_helpers(g: dict[str, Any]) -> None:
27
+ """Inject agent-authored helpers from ``$BS_HOME/agent_helpers.py`` into
28
+ ``g``. No-op when the file is absent. Names already in ``g`` (the core
29
+ primitive surface + stdlib helpers) are protected: a shadowing definition
30
+ is refused with a stderr warning, never silently applied. Underscore-
31
+ prefixed names stay private. A broken file warns but never breaks the
32
+ namespace — the core surface must always come up.
33
+ """
34
+ # Imported lazily to avoid a module-import cycle (memory -> primitives).
35
+ from browserwright.memory.global_mem import home_dir
36
+
37
+ path = home_dir() / "agent_helpers.py"
38
+ if not path.exists():
39
+ return
40
+
41
+ protected = set(g) # core EXPORTS + json/re/time/sys already placed
42
+ try:
43
+ spec = importlib.util.spec_from_file_location(
44
+ "browserwright_agent_helpers", path)
45
+ if spec is None or spec.loader is None:
46
+ return
47
+ module = importlib.util.module_from_spec(spec)
48
+ # Pre-seed the helper module's globals with the core surface so a
49
+ # helper can call ``http_get`` / ``remember`` / ``run_task`` etc. with
50
+ # no import — a function resolves free names against its own module
51
+ # dict, which is this one. (browser-harness requires explicit imports
52
+ # here.)
53
+ module.__dict__.update(
54
+ {k: v for k, v in g.items() if not k.startswith("__")})
55
+ spec.loader.exec_module(module)
56
+ except Exception as exc: # syntax error, import error, anything
57
+ print(f"browserwright: failed to load agent_helpers.py ({path}): "
58
+ f"{type(exc).__name__}: {exc}", file=sys.stderr)
59
+ return
60
+
61
+ for name, value in vars(module).items():
62
+ if name.startswith("_"):
63
+ continue
64
+ if name in protected:
65
+ # Same object (e.g. the helper did `import json`) → harmless skip.
66
+ if g.get(name) is not value:
67
+ print(f"browserwright: agent helper {name!r} shadows a core "
68
+ f"primitive — ignored, keeping core (rename it)",
69
+ file=sys.stderr)
70
+ continue
71
+ g[name] = value
72
+
73
+
74
+ def build_globals() -> dict[str, Any]:
75
+ g: dict[str, Any] = {}
76
+ # Every primitive + every error class.
77
+ for name in browserwright.EXPORTS:
78
+ g[name] = getattr(browserwright, name)
79
+ # Commonly-needed stdlib.
80
+ g["json"] = json
81
+ g["re"] = re
82
+ g["time"] = time
83
+ g["sys"] = sys
84
+ g["__name__"] = "__skill__"
85
+ g["__builtins__"] = __builtins__
86
+ # Phase C: a lazy Playwright `page` / `context` bound to the session's
87
+ # current tab. Both are transparent proxies that DON'T connect until first
88
+ # use, so a pure memory()/site-skill heredoc opens no browser connection.
89
+ # The owning handle is stashed under a private key so the heredoc runner
90
+ # can tear the connection down at heredoc end (see inline.py). It is NOT a
91
+ # core EXPORT — only entry points that drive a heredoc inject it here.
92
+ from .playwright_handle import PlaywrightHandle, _LazyHandleProxy
93
+ handle = PlaywrightHandle()
94
+ g["page"] = _LazyHandleProxy(handle, "page")
95
+ g["context"] = _LazyHandleProxy(handle, "context")
96
+ g["__bw_playwright_handle__"] = handle
97
+ # Phase C: `snapshot()` is the Playwright first-party AI aria snapshot
98
+ # bound to THIS heredoc's `page` (refs → `page.locator("aria-ref=eN")`).
99
+ # The legacy coordinate `snapshot` was removed from EXPORTS in PR3, so this
100
+ # is now the sole observation verb the agent gets — there is nothing left
101
+ # to override.
102
+ from .snapshot import make_snapshot
103
+ g["snapshot"] = make_snapshot(handle)
104
+ # Agent-editable layer last, so helpers can call core primitives.
105
+ _load_agent_helpers(g)
106
+ return g
@@ -0,0 +1,236 @@
1
+ """Transparent smart waiting for Playwright ``Page.goto``.
2
+
3
+ Browserwright agents already know Playwright, so navigation should keep the
4
+ same surface while avoiding Playwright's SPA-hostile default ``wait_until=load``.
5
+ This module patches Page instances in place: callers can keep using
6
+ ``page.goto(...)`` and still receive the normal Playwright ``Response | None``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ import types
12
+ from datetime import timedelta
13
+ from typing import Any
14
+
15
+ from ..errors import PageLoadFailed
16
+
17
+
18
+ _PATCHED = "_bw_smart_goto"
19
+ _ORIG_GOTO = "_bw_orig_goto"
20
+ _CONTEXT_PATCHED = "_bw_smart_new_page"
21
+ _ORIG_NEW_PAGE = "_bw_orig_new_page"
22
+ _STABLE_WINDOW_MS = 1500
23
+ _DEFAULT_TIMEOUT_MS = 60_000
24
+ _DOMCONTENTLOADED_TIMEOUT_MS = 10_000
25
+
26
+
27
+ def patch_context_pages(context: Any) -> None:
28
+ """Patch existing pages and future ``context.new_page()`` results."""
29
+ for page in list(getattr(context, "pages", []) or []):
30
+ patch_page_goto(page)
31
+ if getattr(context, _CONTEXT_PATCHED, False):
32
+ return
33
+
34
+ orig_new_page = context.new_page
35
+
36
+ def new_page(*args: Any, **kwargs: Any) -> Any:
37
+ page = orig_new_page(*args, **kwargs)
38
+ patch_page_goto(page)
39
+ return page
40
+
41
+ try:
42
+ setattr(context, _ORIG_NEW_PAGE, orig_new_page)
43
+ setattr(context, "new_page", new_page)
44
+ setattr(context, _CONTEXT_PATCHED, True)
45
+ except Exception: # noqa: BLE001 - best effort; returned pages still patch.
46
+ return
47
+
48
+
49
+ def patch_page_goto(page: Any) -> Any:
50
+ """Replace one Playwright Page instance's ``goto`` with smart waiting."""
51
+ if getattr(page, _PATCHED, False):
52
+ return page
53
+
54
+ orig_goto = page.goto
55
+
56
+ def smart_goto(self: Any, url: str, *, timeout: int | float | timedelta | None = _DEFAULT_TIMEOUT_MS,
57
+ wait_until: str | None = None, referer: str | None = None) -> Any:
58
+ timeout_ms = _normalize_timeout(timeout)
59
+ network = _NetworkMonitor(self)
60
+ deadline = _deadline_for(timeout_ms)
61
+ try:
62
+ response = orig_goto(url, timeout=timeout_ms,
63
+ wait_until="commit", referer=referer)
64
+ except Exception as exc: # noqa: BLE001 - translate Playwright failures.
65
+ network.detach()
66
+ raise _page_load_failed(url, "commit", exc) from exc
67
+
68
+ _wait_for_domcontentloaded(self, _remaining_timeout_ms(deadline))
69
+ try:
70
+ _smart_wait_settled(self, deadline, network)
71
+ finally:
72
+ network.detach()
73
+ return response
74
+
75
+ try:
76
+ setattr(page, _ORIG_GOTO, orig_goto)
77
+ setattr(page, "goto", types.MethodType(smart_goto, page))
78
+ setattr(page, _PATCHED, True)
79
+ except Exception: # noqa: BLE001
80
+ return page
81
+ return page
82
+
83
+
84
+ def _normalize_timeout(timeout: int | float | timedelta | None) -> int:
85
+ if timeout is None:
86
+ return _DEFAULT_TIMEOUT_MS
87
+ if isinstance(timeout, timedelta):
88
+ return max(0, int(timeout.total_seconds() * 1000))
89
+ try:
90
+ timeout_ms = int(timeout)
91
+ except (TypeError, ValueError):
92
+ return _DEFAULT_TIMEOUT_MS
93
+ return timeout_ms if timeout_ms >= 0 else _DEFAULT_TIMEOUT_MS
94
+
95
+
96
+ def _deadline_for(timeout_ms: int) -> float | None:
97
+ if timeout_ms == 0:
98
+ return None
99
+ return time.monotonic() + (timeout_ms / 1000.0)
100
+
101
+
102
+ def _remaining_timeout_ms(deadline: float | None) -> int:
103
+ if deadline is None:
104
+ return _DOMCONTENTLOADED_TIMEOUT_MS
105
+ return max(1, int((deadline - time.monotonic()) * 1000))
106
+
107
+
108
+ def _wait_for_domcontentloaded(page: Any, remaining_timeout_ms: int) -> None:
109
+ try:
110
+ page.wait_for_load_state(
111
+ "domcontentloaded",
112
+ timeout=_bounded_timeout(remaining_timeout_ms, _DOMCONTENTLOADED_TIMEOUT_MS),
113
+ )
114
+ except Exception:
115
+ pass
116
+
117
+
118
+ def _smart_wait_settled(page: Any, deadline: float | None, network: "_NetworkMonitor") -> None:
119
+ try:
120
+ page.evaluate(_INSTALL_MONITOR_JS)
121
+ except Exception:
122
+ return
123
+
124
+ while deadline is None or time.monotonic() < deadline:
125
+ remaining_ms = None if deadline is None else max(1, int((deadline - time.monotonic()) * 1000))
126
+ poll_ms = 250 if remaining_ms is None else min(250, remaining_ms)
127
+ try:
128
+ settled = page.evaluate(_SETTLED_JS, _STABLE_WINDOW_MS)
129
+ except Exception:
130
+ return
131
+ if settled or network.is_idle(_STABLE_WINDOW_MS):
132
+ return
133
+ try:
134
+ page.wait_for_timeout(poll_ms)
135
+ except Exception:
136
+ return
137
+
138
+
139
+ def _bounded_timeout(total_ms: int, cap_ms: int) -> int:
140
+ if total_ms == 0:
141
+ return cap_ms
142
+ return max(1, min(int(total_ms), cap_ms))
143
+
144
+
145
+ def _page_load_failed(url: str, phase: str, exc: BaseException) -> PageLoadFailed:
146
+ msg = str(exc)
147
+ exc_type = type(exc).__name__
148
+ lower = msg.lower()
149
+ if "timeout" in lower or exc_type == "TimeoutError":
150
+ return PageLoadFailed(
151
+ url,
152
+ phase,
153
+ fix="site did not respond at commit; verify it with http_get(url) or retry",
154
+ )
155
+ if "net::" in msg or "ssl" in lower or "name_not_resolved" in lower:
156
+ return PageLoadFailed(
157
+ url,
158
+ "network",
159
+ fix="check the URL and network; use http_get(url) to verify the site is reachable",
160
+ )
161
+ return PageLoadFailed(
162
+ url,
163
+ "network",
164
+ fix="check the URL and network; use http_get(url) to verify the site is reachable",
165
+ )
166
+
167
+
168
+ class _NetworkMonitor:
169
+ def __init__(self, page: Any) -> None:
170
+ self.page = page
171
+ self.inflight = 0
172
+ self.last_activity = time.monotonic()
173
+
174
+ def on_request(*_args: Any) -> None:
175
+ self.inflight += 1
176
+ self.last_activity = time.monotonic()
177
+
178
+ def on_done(*_args: Any) -> None:
179
+ self.inflight = max(0, self.inflight - 1)
180
+ self.last_activity = time.monotonic()
181
+
182
+ self._handlers = {
183
+ "request": on_request,
184
+ "requestfinished": on_done,
185
+ "requestfailed": on_done,
186
+ }
187
+ for event, handler in self._handlers.items():
188
+ try:
189
+ page.on(event, handler)
190
+ except Exception:
191
+ pass
192
+
193
+ def is_idle(self, stable_window_ms: int) -> bool:
194
+ quiet_s = stable_window_ms / 1000.0
195
+ return self.inflight == 0 and (time.monotonic() - self.last_activity) >= quiet_s
196
+
197
+ def detach(self) -> None:
198
+ for event, handler in self._handlers.items():
199
+ try:
200
+ self.page.off(event, handler)
201
+ except Exception:
202
+ pass
203
+
204
+
205
+ _INSTALL_MONITOR_JS = """
206
+ () => {
207
+ const w = window;
208
+ const now = Date.now();
209
+ if (!w.__bwSmartGoto) {
210
+ const state = { lastMutation: now };
211
+ try {
212
+ const observer = new MutationObserver(() => { state.lastMutation = Date.now(); });
213
+ observer.observe(document.documentElement || document, {
214
+ childList: true,
215
+ subtree: true,
216
+ attributes: true,
217
+ characterData: true,
218
+ });
219
+ state.observer = observer;
220
+ } catch (e) {}
221
+ w.__bwSmartGoto = state;
222
+ }
223
+ return true;
224
+ }
225
+ """
226
+
227
+
228
+ _SETTLED_JS = """
229
+ (stableWindowMs) => {
230
+ const state = window.__bwSmartGoto || {};
231
+ const now = Date.now();
232
+ const lastMutation = state.lastMutation || now;
233
+ return document.readyState === "complete" &&
234
+ (now - lastMutation) >= stableWindowMs;
235
+ }
236
+ """
@@ -0,0 +1,180 @@
1
+ """``browserwright -s <id> -e <code>`` — single-shot code execution.
2
+
3
+ Two paths, chosen by a cheap static pre-check on the code (Phase B, Fork 7):
4
+
5
+ - **In-process** (Phase C path, unchanged): code that touches NONE of
6
+ ``{page, context, snapshot, state, reset}`` — pure ``memory()`` /
7
+ site-skill / ``http_get`` — is exec'd here, in this short-lived process. It
8
+ never spawns or contacts an executor (stays lightweight).
9
+ - **Shipped to the executor** (Phase B path): code that references any of
10
+ those names is shipped WHOLE to the session's resident, per-session executor
11
+ subprocess, where ``page`` / ``context`` are LIVE objects that survive
12
+ across calls and ``state`` is a persistent dict. You cannot return a
13
+ live cross-process ``Page`` into a local ``exec``, so the entire body runs
14
+ there.
15
+
16
+ The old cross-process Skill REPL daemon was removed (P3): it froze
17
+ ``BD_NAME``/backend into a shared SINGLETON and forwarded calls without their
18
+ env — the documented cross-talk accident. Phase B's executor avoids that by
19
+ keying strictly on ``session_id`` (playwriter's ``Map<sessionId, executor>``),
20
+ never a shared singleton.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import io
25
+ import json
26
+ import sys
27
+ import traceback
28
+ from contextlib import redirect_stdout
29
+ from typing import IO
30
+
31
+ from ..errors import BrowserwrightError, serialize
32
+ from . import _namespace
33
+
34
+ # Names whose presence routes the heredoc to the persistent executor (Fork 7).
35
+ # `state` / `reset` are executor-only (live across calls / acts on live
36
+ # objects); `page` / `context` / `snapshot` are live cross-process objects.
37
+ _EXECUTOR_NAMES = frozenset({"page", "context", "snapshot", "state", "reset"})
38
+
39
+
40
+ def _touches_executor_surface(code_obj) -> bool:
41
+ """True iff the compiled code references any executor-only name.
42
+
43
+ Uses ``co_names`` (free/global name references) — cheap and deterministic.
44
+ KNOWN ESCAPE (acceptable): indirect access like ``g = globals(); g['page']``
45
+ evades ``co_names`` — but the fallback (in-process) is only WRONG in that it
46
+ can't actually serve a live ``page``; such code would raise a plain
47
+ ``NameError`` exactly as it does today, never silently misbehave. The common
48
+ case (writing ``page``/``state`` directly) routes correctly. We also scan
49
+ nested code objects (functions/comprehensions) so a name used only inside a
50
+ ``def`` still routes to the executor."""
51
+ seen = [code_obj]
52
+ while seen:
53
+ co = seen.pop()
54
+ if _EXECUTOR_NAMES.intersection(co.co_names):
55
+ return True
56
+ for const in co.co_consts:
57
+ if hasattr(const, "co_names"): # nested code object
58
+ seen.append(const)
59
+ return False
60
+
61
+
62
+ def run(stdin: IO[str]) -> int:
63
+ """Deprecated stdin entrypoint kept only to reject old heredoc usage."""
64
+ code = stdin.read()
65
+ del code
66
+ print("usage: browserwright -s <session-id> -e 'print(snapshot())'",
67
+ file=sys.stderr)
68
+ return 1
69
+
70
+
71
+ def run_code(code: str, *, session_id: str) -> int:
72
+ """Execute inline code for an explicit session id."""
73
+ if not code.strip():
74
+ print("usage: browserwright -s <session-id> -e 'print(snapshot())'",
75
+ file=sys.stderr)
76
+ return 1
77
+
78
+ # P1: refuse loudly at the entrypoint when no session is in scope, then
79
+ # bind that explicit ledger record so primitives drive the session's
80
+ # daemon/backend — never an env-guessed default.
81
+ from ..errors import NoSession
82
+ from ..session import Session, set_session
83
+ from ..session_ctx import resolve_session
84
+ try:
85
+ rec = resolve_session(session_id)
86
+ except NoSession as e:
87
+ print(str(e), file=sys.stderr)
88
+ return e.exit_code
89
+ sess = Session(record=rec)
90
+ set_session(sess)
91
+
92
+ # Static pre-check: ship to the executor iff the code touches the live
93
+ # browser surface; otherwise run the lightweight in-process path.
94
+ try:
95
+ code_obj = compile(code, "<inline>", "exec")
96
+ except SyntaxError:
97
+ # Let the in-process path raise the SyntaxError with a full traceback
98
+ # (identical behaviour to before); never ship un-compilable code.
99
+ code_obj = None
100
+ if code_obj is not None and _touches_executor_surface(code_obj):
101
+ return _run_on_executor(sess, code)
102
+
103
+ # Run in-process. Capture stdout so we can replay it after the exec.
104
+ globals_ = _namespace.build_globals()
105
+ buf = io.StringIO()
106
+ try:
107
+ with redirect_stdout(buf):
108
+ exec(code_obj if code_obj is not None
109
+ else compile(code, "<inline>", "exec"), globals_)
110
+ except BrowserwrightError as e:
111
+ sys.stdout.write(buf.getvalue())
112
+ sys.stderr.write(json.dumps(serialize(e)) + "\n")
113
+ return e.exit_code
114
+ except SystemExit as e:
115
+ sys.stdout.write(buf.getvalue())
116
+ return int(e.code) if isinstance(e.code, int) else 0
117
+ except Exception: # noqa: BLE001
118
+ sys.stdout.write(buf.getvalue())
119
+ sys.stderr.write(traceback.format_exc())
120
+ return 3
121
+ finally:
122
+ # Phase C: tear down the lazy Playwright connection at heredoc end. A
123
+ # no-op when `page`/`context` were never accessed (nothing connected).
124
+ # close() disconnects the CDP transport only — it never closes the
125
+ # user's real tabs/browser. Fully suppressed so cleanup can't change
126
+ # the heredoc's exit code.
127
+ handle = globals_.get("__bw_playwright_handle__")
128
+ if handle is not None:
129
+ try:
130
+ handle.close()
131
+ except Exception: # noqa: BLE001
132
+ pass
133
+ sys.stdout.write(buf.getvalue())
134
+ return 0
135
+
136
+
137
+ def _run_on_executor(sess, code: str) -> int:
138
+ """Ship the whole code body to the session's persistent executor and replay
139
+ its response locally (Phase B path).
140
+
141
+ The executor holds live ``page`` / ``context`` + persistent ``state`` across
142
+ heredoc calls. We print its captured console to stdout, surface any error to
143
+ stderr in the same shape the in-process path uses (``serialize`` dict for a
144
+ BrowserwrightError, else the raw type/msg), and propagate its exit code. The
145
+ in-process ``finally`` teardown does NOT run on this path — no local
146
+ ``__bw_playwright_handle__`` was built; the executor owns teardown."""
147
+ from .._executor.client import run_on_executor
148
+
149
+ try:
150
+ # ExecutorUnavailable is a BrowserwrightError subclass — caught below.
151
+ resp = run_on_executor(sess, code)
152
+ except BrowserwrightError as e:
153
+ sys.stderr.write(json.dumps(serialize(e)) + "\n")
154
+ return e.exit_code
155
+ except Exception: # noqa: BLE001 - never let transport blow up opaque
156
+ sys.stderr.write(traceback.format_exc())
157
+ return 3
158
+
159
+ if resp.console:
160
+ sys.stdout.write(resp.console)
161
+ for w in resp.warnings:
162
+ sys.stderr.write(f"[WARNING] {w}\n")
163
+ for shot in resp.screenshots:
164
+ path = shot.get("path") if isinstance(shot, dict) else None
165
+ if path:
166
+ sys.stderr.write(f"[screenshot] {path}\n")
167
+ if resp.return_value is not None:
168
+ sys.stdout.write(f"[return value] {resp.return_value}\n")
169
+ if resp.truncated:
170
+ sys.stderr.write("[output truncated]\n")
171
+ if resp.error is not None:
172
+ tb = resp.error.get("tb") or resp.error.get("traceback") \
173
+ if isinstance(resp.error, dict) else None
174
+ if tb:
175
+ # Mirror the in-process path: a generic exception writes its full
176
+ # traceback to stderr (the serialized envelope carries it).
177
+ sys.stderr.write(tb if tb.endswith("\n") else tb + "\n")
178
+ else:
179
+ sys.stderr.write(json.dumps(resp.error) + "\n")
180
+ return resp.exit_code