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