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,222 @@
1
+ """cloud backend — v0.5 (spec §7 + §8.1.1).
2
+
3
+ The cloud backend exists to cover what the v0.1 `env` backend couldn't:
4
+ HTTP-header auth and mTLS for remote browser services (Browser Use,
5
+ Browserless, Hyperbrowser, etc.). It still produces an upstream CDP ws
6
+ URL (so `kind=UPSTREAM_WS`, not LOCAL_RELAY) — the difference is that
7
+ the daemon's Mode B upstream-ws connect needs the auth provider's
8
+ headers / ssl context to authenticate the handshake. Mode A is degraded
9
+ but supported: for URL-embeddable auth styles (basic, URL-token), we
10
+ fold credentials into the output URL; for header / mTLS auth, Mode A
11
+ emits a warning that the resulting URL won't authenticate (the user
12
+ needs Mode B serve to actually connect).
13
+
14
+ Endpoint resolution:
15
+ - `wss://...` / `ws://...` → used as-is
16
+ - `https://...` / `http://...` → HTTP GET `{endpoint}/json/version`
17
+ and read `webSocketDebuggerUrl` (same shape as env backend's
18
+ BD_CDP_URL path). For Authorization-header auth, the discovery GET
19
+ also carries the header — otherwise some services 401 the GET.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from typing import Any
25
+
26
+ import httpx
27
+
28
+ from ..auth import AuthProvider, build_auth_provider
29
+ from ..config import Config
30
+ from ..errors import Unavailable, UserError
31
+ from .base import Backend, DoctorResult, ResolveResult
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class CloudBackend(Backend):
37
+ name = "cloud"
38
+ kind = "UPSTREAM_WS"
39
+ recommended_mode: str = "B" # header / mTLS auth needs Mode B to inject
40
+ ux_cost = "auth-required" # v0.5 — new enum value, doctor contract
41
+
42
+ def __init__(self, cfg: Config):
43
+ self._cfg = cfg
44
+ self._cloud = cfg.backends.cloud
45
+
46
+ # ---- internal: build the AuthProvider (cached per-instance) ---------
47
+
48
+ _provider_cache: AuthProvider | None = None
49
+
50
+ def _provider(self) -> AuthProvider | None:
51
+ if self._provider_cache is not None:
52
+ return self._provider_cache
53
+ if not self._cloud.auth_kind:
54
+ return None
55
+ try:
56
+ p = build_auth_provider(self._cloud.auth_kind, self._cloud.auth)
57
+ except UserError:
58
+ raise
59
+ self._provider_cache = p
60
+ return p
61
+
62
+ # ---- probe ----------------------------------------------------------
63
+
64
+ def _extras(self, *, configured: bool) -> dict:
65
+ """Doctor JSON `extras` contract (v0.5) for cloud backend. Matches
66
+ skill-impl-2's `_extension_backend_available()` mirror pattern —
67
+ skill reads `entry["extras"]["configured"]` in install wizard.
68
+
69
+ Schema (stable, additions are minor backend bumps not schema-level):
70
+ provider: str (browser-use | browserless | hyperbrowser | generic)
71
+ endpoint: str | None (configured ws / https URL, or None)
72
+ auth_kind: str | None (bearer | basic | mtls | oauth2 | None)
73
+ configured: bool (True iff endpoint + auth_kind both present
74
+ AND auth provider loads without raising)
75
+ """
76
+ return {
77
+ "provider": self._cloud.provider_hint or "generic",
78
+ "endpoint": self._cloud.endpoint,
79
+ "auth_kind": self._cloud.auth_kind,
80
+ "configured": configured,
81
+ }
82
+
83
+ async def probe(self) -> DoctorResult:
84
+ """Doctor probe — never opens a ws and **never** TCP-connects to the
85
+ cloud endpoint (spec §H3 / §5.2: zero side effects).
86
+
87
+ Contract (v0.5):
88
+ - `available=true` iff cloud config is complete AND auth provider
89
+ loads cleanly. We do NOT round-trip to the cloud server — that
90
+ would mean a TLS handshake (TCP connect + cert exchange) which
91
+ is a network side effect doctor must not have.
92
+ - `available=false + detail="not configured; ..."` when no endpoint
93
+ is configured.
94
+ - `available=false + detail="auth misconfigured: ..."` when auth
95
+ provider construction or eval fails (bad token_env, missing
96
+ cert file).
97
+ - `ux_cost="auth-required"` always (new enum value v0.5).
98
+ - `extras` carries `{provider, endpoint, auth_kind, configured}`.
99
+ """
100
+ ep = self._cloud.endpoint
101
+ if not ep:
102
+ return DoctorResult(
103
+ name=self.name,
104
+ available=False,
105
+ ws_url=None,
106
+ detail="not configured; configure under [backends.cloud] in config.toml",
107
+ needs_user_action=(
108
+ "configure `[backends.cloud]` in config.toml — "
109
+ "see README §cloud backend"),
110
+ ux_cost=self.ux_cost,
111
+ extras=self._extras(configured=False),
112
+ )
113
+ try:
114
+ provider = self._provider()
115
+ except UserError as e:
116
+ return DoctorResult(
117
+ name=self.name, available=False, ws_url=None,
118
+ detail=f"auth misconfigured: {e}",
119
+ needs_user_action="fix [backends.cloud.auth.*] in config.toml",
120
+ ux_cost=self.ux_cost,
121
+ extras=self._extras(configured=False),
122
+ )
123
+
124
+ # Validate the AuthProvider can produce headers / ssl context. This
125
+ # touches local config (env vars, cert files) but does NOT do any
126
+ # network I/O — preserves the zero-side-effect doctor contract.
127
+ try:
128
+ if provider is not None:
129
+ _ = await provider.headers()
130
+ # ssl_context() reads cert files; raises UserError on bad path.
131
+ _ = provider.ssl_context()
132
+ except UserError as e:
133
+ return DoctorResult(
134
+ name=self.name, available=False, ws_url=None,
135
+ detail=f"auth misconfigured: {e}",
136
+ needs_user_action="check env vars / cert files",
137
+ ux_cost=self.ux_cost,
138
+ extras=self._extras(configured=False),
139
+ )
140
+
141
+ auth_kind = self._cloud.auth_kind or "(none)"
142
+ hint = self._cloud.provider_hint or "generic"
143
+ return DoctorResult(
144
+ name=self.name,
145
+ available=True,
146
+ ws_url=None,
147
+ detail=f"provider={hint} (auth_kind={auth_kind}, endpoint={ep})",
148
+ ux_cost=self.ux_cost,
149
+ extras=self._extras(configured=True),
150
+ )
151
+
152
+ # ---- resolve --------------------------------------------------------
153
+
154
+ async def resolve(self, timeout: float) -> ResolveResult:
155
+ ep = self._cloud.endpoint
156
+ if not ep:
157
+ raise Unavailable(
158
+ "cloud backend has no endpoint configured",
159
+ attempts={self.name: "no endpoint"},
160
+ )
161
+ provider = self._provider()
162
+ # Pre-baked direct ws URL — just fold URL-embeddable auth in.
163
+ if ep.startswith(("ws://", "wss://")):
164
+ url = ep
165
+ if provider is not None:
166
+ url = await provider.url_with_auth(url)
167
+ extras: dict[str, Any] = {
168
+ "auth_kind": self._cloud.auth_kind or "none",
169
+ "provider": self._cloud.provider_hint or "generic",
170
+ }
171
+ if provider is not None and not provider.supports_websocket_auth():
172
+ # OAuth2 stub etc. — surface the limitation explicitly.
173
+ extras["warning"] = (
174
+ f"auth_kind={self._cloud.auth_kind} doesn't support "
175
+ "websocket auth in v0.5; Mode A URL will not "
176
+ "authenticate by itself")
177
+ return ResolveResult(ws_url=url, backend=self.name, extras=extras)
178
+
179
+ # HTTP endpoint → discover via /json/version (matching env backend's
180
+ # BD_CDP_URL shape).
181
+ try:
182
+ headers = await provider.headers() if provider is not None else {}
183
+ except UserError as e:
184
+ raise Unavailable(
185
+ f"cloud backend auth headers unresolvable: {e}",
186
+ attempts={self.name: str(e)},
187
+ )
188
+ url = ep.rstrip("/") + "/json/version"
189
+ try:
190
+ async with httpx.AsyncClient(
191
+ timeout=timeout, trust_env=False, mounts={}) as client:
192
+ resp = await client.get(url, headers=headers)
193
+ except (httpx.HTTPError, OSError) as e:
194
+ raise Unavailable(
195
+ f"cloud backend discovery failed: {e}",
196
+ attempts={self.name: f"{type(e).__name__}: {e}"},
197
+ )
198
+ if resp.status_code != 200:
199
+ raise Unavailable(
200
+ f"cloud backend discovery returned HTTP {resp.status_code}",
201
+ attempts={self.name: f"HTTP {resp.status_code} from {url}"},
202
+ )
203
+ try:
204
+ body = resp.json()
205
+ except ValueError:
206
+ body = {}
207
+ ws = body.get("webSocketDebuggerUrl") if isinstance(body, dict) else None
208
+ if not isinstance(ws, str) or not ws:
209
+ raise Unavailable(
210
+ f"cloud backend /json/version has no webSocketDebuggerUrl",
211
+ attempts={self.name: f"response body: {body!r}"},
212
+ )
213
+ if provider is not None:
214
+ ws = await provider.url_with_auth(ws)
215
+ return ResolveResult(
216
+ ws_url=ws,
217
+ backend=self.name,
218
+ extras={
219
+ "auth_kind": self._cloud.auth_kind or "none",
220
+ "provider": self._cloud.provider_hint or "generic",
221
+ },
222
+ )
@@ -0,0 +1,119 @@
1
+ """env backend — caller injects the ws URL out-of-band.
2
+
3
+ Spec §8.1 + §8.1.1: BD_CDP_WS is trusted verbatim (no parsing, no rewriting —
4
+ that's the whole point of the env path for cloud / fingerprint browsers with
5
+ URL-embedded tokens). BD_CDP_URL goes through `/json/version` to pull the
6
+ webSocketDebuggerUrl field, same as rdp but pointed at an arbitrary host.
7
+
8
+ BU_CDP_WS / BU_CDP_URL are compat aliases honored at the Config layer
9
+ (config.py records `cdp_ws_source`); doctor surfaces a "please migrate" hint
10
+ when those fired.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import httpx
15
+
16
+ from ..config import Config
17
+ from ..errors import Unavailable
18
+ from .base import Backend, DoctorResult, ResolveResult
19
+
20
+
21
+ class EnvBackend(Backend):
22
+ name = "env"
23
+ kind = "UPSTREAM_WS"
24
+ recommended_mode: str = "A"
25
+ ux_cost = "none"
26
+
27
+ def __init__(self, cfg: Config):
28
+ self._cfg = cfg
29
+
30
+ # ------------------------------------------------------------------ probe
31
+
32
+ async def probe(self) -> DoctorResult:
33
+ cfg = self._cfg
34
+ if cfg.cdp_ws:
35
+ return DoctorResult(
36
+ name=self.name,
37
+ available=True,
38
+ ws_url=None, # doctor never opens a ws — spec §5.2 contract
39
+ detail=self._origin_detail("BD_CDP_WS" if cfg.cdp_ws_source == "BD_CDP_WS"
40
+ else "BU_CDP_WS"),
41
+ ux_cost=self.ux_cost,
42
+ )
43
+ if cfg.cdp_url:
44
+ return DoctorResult(
45
+ name=self.name,
46
+ available=True,
47
+ ws_url=None,
48
+ detail=self._origin_detail("BD_CDP_URL" if cfg.cdp_url_source == "BD_CDP_URL"
49
+ else "BU_CDP_URL"),
50
+ ux_cost=self.ux_cost,
51
+ )
52
+ return DoctorResult(
53
+ name=self.name,
54
+ available=False,
55
+ ws_url=None,
56
+ detail="BD_CDP_WS not set, BD_CDP_URL not set",
57
+ ux_cost=self.ux_cost,
58
+ )
59
+
60
+ def _origin_detail(self, source: str) -> str:
61
+ if source.startswith("BU_"):
62
+ return f"{source} set (deprecated, please rename to BD_{source[3:]})"
63
+ return f"{source} set"
64
+
65
+ # ---------------------------------------------------------------- resolve
66
+
67
+ async def resolve(self, timeout: float) -> ResolveResult:
68
+ cfg = self._cfg
69
+ if cfg.cdp_ws:
70
+ return ResolveResult(ws_url=cfg.cdp_ws, backend=self.name, extras={})
71
+ if cfg.cdp_url:
72
+ ws = await _resolve_via_json_version(cfg.cdp_url, timeout)
73
+ return ResolveResult(
74
+ ws_url=ws,
75
+ backend=self.name,
76
+ extras={"discovery_url": cfg.cdp_url},
77
+ )
78
+ raise Unavailable(
79
+ "env backend: neither BD_CDP_WS nor BD_CDP_URL is set",
80
+ attempts={self.name: "BD_CDP_WS not set, BD_CDP_URL not set"},
81
+ )
82
+
83
+
84
+ async def _resolve_via_json_version(base_url: str, timeout: float) -> str:
85
+ """HTTP GET {base_url}/json/version and return webSocketDebuggerUrl.
86
+
87
+ Spec §8.1: this is the standard CDP HTTP discovery shape that all real and
88
+ fingerprint browsers, plus most cloud providers, agree on. We keep this
89
+ function shared with `rdp`-style probes (re-imported there) so the two
90
+ paths can never diverge on edge handling (timeout / non-200 / missing key).
91
+ """
92
+ url = f"{base_url.rstrip('/')}/json/version"
93
+ try:
94
+ async with httpx.AsyncClient(timeout=timeout) as client:
95
+ resp = await client.get(url)
96
+ except (httpx.HTTPError, OSError) as e:
97
+ raise Unavailable(
98
+ f"env backend: HTTP discovery failed for {url}: {e}",
99
+ attempts={"env": f"GET {url} -> {type(e).__name__}: {e}"},
100
+ ) from e
101
+ if resp.status_code != 200:
102
+ raise Unavailable(
103
+ f"env backend: {url} returned HTTP {resp.status_code}",
104
+ attempts={"env": f"GET {url} -> HTTP {resp.status_code}"},
105
+ )
106
+ try:
107
+ body = resp.json()
108
+ except ValueError as e:
109
+ raise Unavailable(
110
+ f"env backend: {url} returned non-JSON body: {e}",
111
+ attempts={"env": f"GET {url} -> non-JSON"},
112
+ ) from e
113
+ ws = body.get("webSocketDebuggerUrl") if isinstance(body, dict) else None
114
+ if not isinstance(ws, str) or not ws:
115
+ raise Unavailable(
116
+ f"env backend: {url} JSON has no webSocketDebuggerUrl field",
117
+ attempts={"env": f"GET {url} -> missing webSocketDebuggerUrl"},
118
+ )
119
+ return ws
@@ -0,0 +1,185 @@
1
+ """extension backend — v0.4 real implementation (spec §8.4).
2
+
3
+ Architecture: this backend doesn't return an upstream ws URL — Chrome's
4
+ CDP isn't the upstream here. Instead, the daemon (Mode B `serve`) starts a
5
+ local relay ws server (`server/relay.py`) on `127.0.0.1:19989`, and the
6
+ user's Chrome extension connects TO us. The daemon then proxies standard
7
+ CDP between Skill clients and the extension's `chrome.debugger` API.
8
+
9
+ Implications:
10
+
11
+ - `kind=LOCAL_RELAY` (spec §4.3): not a URL passthrough; the daemon
12
+ emulates the upstream side.
13
+ - `resolve()` in Mode A still raises — Mode A `url` is meaningless when the
14
+ daemon IS the relay; Skill must drive Mode B.
15
+ - `probe()` does a real check: GET `http://127.0.0.1:19989/__status__` and
16
+ inspect the response for a connected extension. If reachable + at least
17
+ one extension has sent `hello`, `available=true`.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import json
23
+ import logging
24
+ from typing import Any
25
+
26
+ import httpx
27
+
28
+ from ..config import Config
29
+ from ..errors import Unavailable
30
+ from .base import Backend, DoctorResult, ResolveResult
31
+ from ..server.relay import DEFAULT_RELAY_PORT
32
+ from browserwright.version import EXTENSION_PROTOCOL_VERSION, package_version
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class ExtensionBackend(Backend):
38
+ name = "extension"
39
+ kind = "LOCAL_RELAY"
40
+ recommended_mode: str = "B"
41
+ ux_cost = "extension-permission"
42
+
43
+ def __init__(self, cfg: Config):
44
+ self._cfg = cfg
45
+ # v0.5.3 F-5 / Task #24: single source of truth for host+port.
46
+ # Honors CLI --extension-port > BD_EXTENSION_PORT > toml port >
47
+ # toml relay_url > DEFAULT_RELAY_PORT.
48
+ self._host, self._port = cfg.backends.extension.resolved_host_port()
49
+
50
+ def caps(self) -> dict:
51
+ # Attaches to the user's existing Chrome; isolates sessions via tab
52
+ # groups, not browser contexts (P4).
53
+ return {"owns_browser": False, "supports_browser_context": False}
54
+
55
+ async def probe(self) -> DoctorResult:
56
+ """HTTP GET 127.0.0.1:19989/__status__.
57
+
58
+ Three outcomes:
59
+ 1. connection refused → no daemon-serve running with extension
60
+ backend → available=false + needs_user_action
61
+ 2. running but `extensions == 0` → relay up, user hasn't installed
62
+ / loaded the extension → available=false + needs_user_action
63
+ 3. running with extensions → available=true
64
+ """
65
+ url = f"http://127.0.0.1:{self._port}/__status__"
66
+ # `trust_env=False` + explicit `mounts={}` makes httpx ignore the
67
+ # user's HTTPS_PROXY / SOCKS_PROXY / ALL_PROXY env vars. Loopback
68
+ # traffic must never go through a proxy — same trick the other
69
+ # backends use for their `/json/version` probes.
70
+ try:
71
+ async with httpx.AsyncClient(
72
+ timeout=2.0, trust_env=False, mounts={},
73
+ ) as client:
74
+ resp = await client.get(url)
75
+ except (httpx.ConnectError, httpx.ConnectTimeout):
76
+ return DoctorResult(
77
+ name=self.name,
78
+ available=False,
79
+ ws_url=None,
80
+ detail=(f"no extension relay listening on 127.0.0.1:{self._port} "
81
+ f"— start `browserwright-daemon serve`"),
82
+ ux_warning=None,
83
+ needs_user_action=(
84
+ "start the single global daemon, "
85
+ "then load the Chrome extension from `chrome-extension/`"),
86
+ ux_cost=self.ux_cost,
87
+ )
88
+ except Exception as e:
89
+ return DoctorResult(
90
+ name=self.name,
91
+ available=False,
92
+ ws_url=None,
93
+ detail=f"relay status probe failed: {e!r}",
94
+ ux_warning=None,
95
+ needs_user_action="check daemon logs",
96
+ ux_cost=self.ux_cost,
97
+ )
98
+
99
+ if resp.status_code != 200:
100
+ return DoctorResult(
101
+ name=self.name,
102
+ available=False,
103
+ ws_url=None,
104
+ detail=f"relay /__status__ returned HTTP {resp.status_code}",
105
+ needs_user_action="restart daemon-serve",
106
+ ux_cost=self.ux_cost,
107
+ )
108
+ try:
109
+ status = resp.json()
110
+ except (ValueError, json.JSONDecodeError):
111
+ status = {}
112
+
113
+ ext_count = int(status.get("extensions", 0))
114
+ if ext_count == 0:
115
+ return DoctorResult(
116
+ name=self.name,
117
+ available=False,
118
+ ws_url=None,
119
+ detail=("extension relay is running but no Chrome extension "
120
+ "has connected yet"),
121
+ needs_user_action=(
122
+ "load `chrome-extension/` as an unpacked extension in "
123
+ "Chrome (chrome://extensions/ → enable Developer mode "
124
+ "→ Load unpacked)"),
125
+ ux_cost=self.ux_cost,
126
+ )
127
+
128
+ install_ids = status.get("install_ids") or []
129
+ details = status.get("extension_details") or []
130
+ incompatible = [
131
+ item for item in details
132
+ if item.get("compatible") is False
133
+ ]
134
+ version_mismatch = [
135
+ item for item in details
136
+ if item.get("browserwright_version")
137
+ and item.get("browserwright_version") != package_version()
138
+ ]
139
+ if incompatible:
140
+ return DoctorResult(
141
+ name=self.name,
142
+ available=False,
143
+ ws_url=None,
144
+ detail=(
145
+ "extension relay connected, but protocol is incompatible "
146
+ f"(daemon expects {EXTENSION_PROTOCOL_VERSION})"
147
+ ),
148
+ ux_warning="reload the unpacked extension from the matching release",
149
+ needs_user_action="reload `chrome-extension/` in Chrome",
150
+ ux_cost=self.ux_cost,
151
+ )
152
+ warning = None
153
+ if version_mismatch:
154
+ seen = sorted({
155
+ str(item.get("browserwright_version"))
156
+ for item in version_mismatch
157
+ if item.get("browserwright_version")
158
+ })
159
+ warning = (
160
+ f"extension version(s) {seen} do not match package "
161
+ f"{package_version()}; reload the unpacked extension"
162
+ )
163
+ tabs = int(status.get("tab_count", 0))
164
+ return DoctorResult(
165
+ name=self.name,
166
+ available=True,
167
+ ws_url=None,
168
+ detail=(f"{ext_count} extension(s) connected "
169
+ f"(install_ids={install_ids}, attached tabs={tabs})"),
170
+ ux_warning=warning,
171
+ needs_user_action=None,
172
+ ux_cost=self.ux_cost,
173
+ )
174
+
175
+ async def resolve(self, timeout: float) -> ResolveResult:
176
+ """Mode A `url` isn't meaningful for LOCAL_RELAY: there's no
177
+ externally-connectable upstream ws — the daemon IS the relay. So we
178
+ raise Unavailable with a hint that points to Mode B.
179
+ """
180
+ raise Unavailable(
181
+ "extension backend is a LOCAL_RELAY — it cannot be used via "
182
+ "`browserwright-daemon url`. Run `browserwright-daemon serve` "
183
+ "instead and connect via the daemon's unix socket.",
184
+ attempts={self.name: "LOCAL_RELAY backend requires Mode B serve"},
185
+ )