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,353 @@
|
|
|
1
|
+
"""launch-chrome subcommand — H9 install-wizard helper.
|
|
2
|
+
|
|
3
|
+
Spec §5.5: locate Chrome → allocate user-data-dir → spawn detached → poll
|
|
4
|
+
`DevToolsActivePort` → output ws URL → write pid file → exit. The Chrome
|
|
5
|
+
process stays alive (detached, in its own process group), so Skill can later
|
|
6
|
+
`kill $(cat pidfile)` to shut it down.
|
|
7
|
+
|
|
8
|
+
Important constraints (spec §5.5):
|
|
9
|
+
- We don't auto-attach. We just launch and print the URL.
|
|
10
|
+
- We don't take custom --no-sandbox / --lang flags. (Open question §10 → punt.)
|
|
11
|
+
- After exit, DevToolsActivePort is Chrome's responsibility to clean up.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from .config import Config, check_name
|
|
27
|
+
from .errors import ChromeBinaryNotFound, UserError, Unavailable
|
|
28
|
+
from .platforms import cache_dir, discover_chrome_binary, profile_paths, runtime_dir
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DEFAULT_PORT = 0 # let the OS pick when --port not given (spec §5.5 step 3)
|
|
32
|
+
DEFAULT_TIMEOUT = 30.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def launch_chrome(
|
|
36
|
+
cfg: Config,
|
|
37
|
+
*,
|
|
38
|
+
profile: str = "isolated",
|
|
39
|
+
persistent: bool = True,
|
|
40
|
+
chrome_binary: str | None = None,
|
|
41
|
+
port: int | None = None,
|
|
42
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
43
|
+
allow_default_profile: bool = False,
|
|
44
|
+
extra_args: list[str] | None = None,
|
|
45
|
+
) -> dict:
|
|
46
|
+
"""Launch Chrome detached with --remote-debugging-port + isolated profile.
|
|
47
|
+
|
|
48
|
+
Returns the same shape as `url --json`:
|
|
49
|
+
{schema_version: 1, ws_url, backend: "rdp", extras: {isolated_profile, profile_path, pid}}
|
|
50
|
+
|
|
51
|
+
`allow_default_profile=True` (or env `BD_LAUNCH_CHROME_ALLOW_DEFAULT_PROFILE=1`)
|
|
52
|
+
is the expert escape hatch for the §11 guard — see `_check_not_default_profile`.
|
|
53
|
+
|
|
54
|
+
`extra_args` (optional list) is appended to the Chrome argv verbatim, after
|
|
55
|
+
the framework's own flags. Used by the E2E harness to inject
|
|
56
|
+
`--load-extension=...`. Caller is responsible for shell-escaping.
|
|
57
|
+
"""
|
|
58
|
+
check_name(profile)
|
|
59
|
+
|
|
60
|
+
# 1) Chrome binary.
|
|
61
|
+
binary = discover_chrome_binary(chrome_binary or cfg.chrome_binary)
|
|
62
|
+
if binary is None:
|
|
63
|
+
raise ChromeBinaryNotFound(
|
|
64
|
+
"could not locate a Chrome binary. Set BD_CHROME_BINARY or pass "
|
|
65
|
+
"--chrome-binary."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# 2) user-data-dir.
|
|
69
|
+
user_data_dir = _allocate_data_dir(profile, persistent=persistent)
|
|
70
|
+
user_data_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
# 2.1) **Default-profile guard** (v0.5 — Task #11).
|
|
73
|
+
# If `user_data_dir` is the OS-default Chrome profile, refuse. Launching
|
|
74
|
+
# Chrome with `--remote-debugging-port` against the user's daily profile
|
|
75
|
+
# permanently taints it: Chrome writes `DevToolsActivePort`, starts
|
|
76
|
+
# LISTEN on the requested port, and every subsequent ws upgrade triggers
|
|
77
|
+
# Chrome's "Allow remote debugging?" popup. This is the **root cause**
|
|
78
|
+
# of the 2026-05-18 popup storm — see chrome-popup-accumulation-bug
|
|
79
|
+
# memory for forensics. The escape hatch exists for the rare expert use
|
|
80
|
+
# case (someone deliberately wants their daily Chrome on CDP).
|
|
81
|
+
_check_not_default_profile(
|
|
82
|
+
user_data_dir,
|
|
83
|
+
allow=(allow_default_profile or _truthy_env(
|
|
84
|
+
"BD_LAUNCH_CHROME_ALLOW_DEFAULT_PROFILE")),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# 3) Port.
|
|
88
|
+
use_port = DEFAULT_PORT if port is None else port
|
|
89
|
+
|
|
90
|
+
# 4) Spawn detached.
|
|
91
|
+
args = [
|
|
92
|
+
str(binary),
|
|
93
|
+
f"--user-data-dir={user_data_dir}",
|
|
94
|
+
f"--remote-debugging-port={use_port}",
|
|
95
|
+
# `--no-first-run` + `--no-default-browser-check` keep an isolated
|
|
96
|
+
# profile from showing welcome dialogs on first launch. They don't
|
|
97
|
+
# affect remote-debugging behavior — just UI.
|
|
98
|
+
"--no-first-run",
|
|
99
|
+
"--no-default-browser-check",
|
|
100
|
+
# Chrome 121+ rejects the ws upgrade with HTTP 403 unless the caller's
|
|
101
|
+
# Origin is on the allow-list (origin-based CSRF defense). Skill /
|
|
102
|
+
# cdp-use opens the ws from a Python process with no Origin header —
|
|
103
|
+
# which Chrome 121+ treats as *not allowed* by default. We pass `*`
|
|
104
|
+
# because the user-data-dir is already an isolation boundary (no
|
|
105
|
+
# session cookies / no auto-login) — same posture as DevTools itself.
|
|
106
|
+
"--remote-allow-origins=*",
|
|
107
|
+
# Disable OS keychain integration. On macOS, Chrome otherwise prompts
|
|
108
|
+
# for the login keychain password on every fresh-profile start ("…wants
|
|
109
|
+
# to use confidential information stored in Chromium Safe Storage…"),
|
|
110
|
+
# which blocks automation. The isolated user-data-dir has nothing
|
|
111
|
+
# encrypted to begin with, so the basic/mock store is functionally
|
|
112
|
+
# equivalent. Same defaults Playwright / Puppeteer / browser-use ship.
|
|
113
|
+
"--password-store=basic",
|
|
114
|
+
"--use-mock-keychain",
|
|
115
|
+
]
|
|
116
|
+
if extra_args:
|
|
117
|
+
args.extend(extra_args)
|
|
118
|
+
proc = subprocess.Popen(
|
|
119
|
+
args,
|
|
120
|
+
stdout=subprocess.DEVNULL,
|
|
121
|
+
stderr=subprocess.DEVNULL,
|
|
122
|
+
**_spawn_kwargs(),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# 5) Wait for Chrome to be reachable.
|
|
126
|
+
#
|
|
127
|
+
# Primary signal: DevToolsActivePort file (gives us both the chosen port
|
|
128
|
+
# and the ws path in one read). Secondary signal: /json/version on the
|
|
129
|
+
# known port — only available when --port N was explicit.
|
|
130
|
+
#
|
|
131
|
+
# The secondary path covers a Chrome 148 macOS quirk where, with the
|
|
132
|
+
# user's primary Chrome already running, the spawned child Chrome answers
|
|
133
|
+
# `/json/version` on the requested port but never writes
|
|
134
|
+
# DevToolsActivePort (Skill team field report May 2026). When --port 0
|
|
135
|
+
# we have no fallback because we don't know what port Chrome picked.
|
|
136
|
+
actual_port, ws_path = await _wait_for_chrome_ready(
|
|
137
|
+
proc, user_data_dir, requested_port=port, timeout=timeout,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# 6) Build ws URL.
|
|
141
|
+
ws_url = f"ws://127.0.0.1:{actual_port}{ws_path}"
|
|
142
|
+
|
|
143
|
+
# 7) Write pid file. Best-effort; missing dir / permission denied just
|
|
144
|
+
# surfaces as a warning in extras.
|
|
145
|
+
pid = proc.pid
|
|
146
|
+
pidfile_err: str | None = None
|
|
147
|
+
pidfile = runtime_dir() / f"browserwright-daemon-chrome-{profile}.pid"
|
|
148
|
+
try:
|
|
149
|
+
pidfile.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
pidfile.write_text(f"{pid}\n")
|
|
151
|
+
except OSError as e:
|
|
152
|
+
pidfile_err = f"could not write pid file {pidfile}: {e}"
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"schema_version": 1,
|
|
156
|
+
"ws_url": ws_url,
|
|
157
|
+
"backend": "rdp",
|
|
158
|
+
"extras": {
|
|
159
|
+
"isolated_profile": True,
|
|
160
|
+
"profile_path": str(user_data_dir),
|
|
161
|
+
"pid": pid,
|
|
162
|
+
"pid_file": str(pidfile) if pidfile_err is None else None,
|
|
163
|
+
"pid_file_error": pidfile_err,
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---- helpers ---------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _wait_for_chrome_ready(
|
|
172
|
+
proc: subprocess.Popen,
|
|
173
|
+
user_data_dir: Path,
|
|
174
|
+
*,
|
|
175
|
+
requested_port: int | None,
|
|
176
|
+
timeout: float,
|
|
177
|
+
) -> tuple[str, str]:
|
|
178
|
+
"""Poll DevToolsActivePort first; fall back to /json/version when an
|
|
179
|
+
explicit port was requested. Returns (port_str, ws_path).
|
|
180
|
+
|
|
181
|
+
Why two signals?
|
|
182
|
+
- DevToolsActivePort is the canonical Chrome signal — it carries the
|
|
183
|
+
ws path so we can build the URL without an extra HTTP roundtrip.
|
|
184
|
+
- But Chrome 148 on macOS, when invoked while the user's primary Chrome
|
|
185
|
+
is already running, sometimes never writes the file (the new instance
|
|
186
|
+
gets bootstrapped through a different code path). It DOES answer
|
|
187
|
+
`/json/version` on the requested port — so when --port was explicit,
|
|
188
|
+
we can resolve via the HTTP discovery shape that rdp already uses.
|
|
189
|
+
|
|
190
|
+
We poll both in the same loop instead of waiting full `timeout` on one
|
|
191
|
+
then the other. Whichever wins first answers; the other never runs.
|
|
192
|
+
"""
|
|
193
|
+
active_file = user_data_dir / "DevToolsActivePort"
|
|
194
|
+
fallback_url = (
|
|
195
|
+
f"http://127.0.0.1:{requested_port}/json/version"
|
|
196
|
+
if requested_port is not None and requested_port > 0
|
|
197
|
+
else None
|
|
198
|
+
)
|
|
199
|
+
deadline = time.monotonic() + timeout
|
|
200
|
+
last_http_err: str | None = None
|
|
201
|
+
# Chrome 148 macOS quirk (Skill team field report May 2026):
|
|
202
|
+
# the launcher binary fork-exec's the real Chrome process and then
|
|
203
|
+
# exits with code 126. The grandchild Chrome continues running, writes
|
|
204
|
+
# DevToolsActivePort, and answers /json/version normally. Our `proc`
|
|
205
|
+
# handle is the short-lived parent. So we must NOT fail the loop the
|
|
206
|
+
# instant `proc.poll() is not None`; we have to keep checking the
|
|
207
|
+
# DevToolsActivePort + HTTP signals until the actual `timeout`. We
|
|
208
|
+
# still record the proc exit so the timeout error message can be
|
|
209
|
+
# precise.
|
|
210
|
+
child_exited_at: float | None = None
|
|
211
|
+
child_exit_code: int | None = None
|
|
212
|
+
|
|
213
|
+
while time.monotonic() < deadline:
|
|
214
|
+
# Note when the child died (could be benign fork-exec hand-off OR a
|
|
215
|
+
# real failure like SingletonLock). Keep polling either way.
|
|
216
|
+
if proc.poll() is not None and child_exited_at is None:
|
|
217
|
+
child_exited_at = time.monotonic()
|
|
218
|
+
child_exit_code = proc.returncode
|
|
219
|
+
|
|
220
|
+
# Primary: DevToolsActivePort.
|
|
221
|
+
try:
|
|
222
|
+
lines = active_file.read_text().splitlines()
|
|
223
|
+
if len(lines) >= 2 and lines[0].strip() and lines[1].strip():
|
|
224
|
+
return lines[0].strip(), lines[1].strip()
|
|
225
|
+
except (FileNotFoundError, OSError):
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
# Secondary: /json/version on the known port. We only try this when
|
|
229
|
+
# --port was explicit — with --port 0 we don't know what port Chrome
|
|
230
|
+
# picked, so DevToolsActivePort is our only option.
|
|
231
|
+
if fallback_url is not None:
|
|
232
|
+
try:
|
|
233
|
+
async with httpx.AsyncClient(timeout=0.5, trust_env=False) as client:
|
|
234
|
+
resp = await client.get(fallback_url)
|
|
235
|
+
if resp.status_code == 200:
|
|
236
|
+
body = resp.json()
|
|
237
|
+
ws_url_full = body.get("webSocketDebuggerUrl") if isinstance(body, dict) else None
|
|
238
|
+
if isinstance(ws_url_full, str) and ws_url_full:
|
|
239
|
+
# ws_url_full is `ws://127.0.0.1:N/devtools/browser/UUID`
|
|
240
|
+
# — split into port + path for the caller's URL builder.
|
|
241
|
+
from urllib.parse import urlparse
|
|
242
|
+
parsed = urlparse(ws_url_full)
|
|
243
|
+
port_str = str(parsed.port or requested_port)
|
|
244
|
+
ws_path = parsed.path
|
|
245
|
+
return port_str, ws_path
|
|
246
|
+
except (httpx.HTTPError, OSError) as e:
|
|
247
|
+
last_http_err = f"{type(e).__name__}: {e}"
|
|
248
|
+
|
|
249
|
+
await asyncio.sleep(0.1)
|
|
250
|
+
|
|
251
|
+
# Timed out. Distinguish two failure modes for the error message:
|
|
252
|
+
# (a) child exited AND nothing answered → likely SingletonLock or
|
|
253
|
+
# Chrome flag rejection. Mention the exit code prominently.
|
|
254
|
+
# (b) child still alive but no DevToolsActivePort / no /json/version →
|
|
255
|
+
# Chrome is running but not serving CDP — bad flags, port already
|
|
256
|
+
# bound by something else, sandbox failure, etc.
|
|
257
|
+
# Only terminate when we know the proc is still ours to kill (case b).
|
|
258
|
+
if child_exited_at is None:
|
|
259
|
+
with _silent():
|
|
260
|
+
proc.terminate()
|
|
261
|
+
|
|
262
|
+
reasons: list[str] = []
|
|
263
|
+
if child_exit_code is not None:
|
|
264
|
+
reasons.append(
|
|
265
|
+
f"launcher process exited with code {child_exit_code} after "
|
|
266
|
+
f"~{child_exited_at and (child_exited_at - (deadline - timeout)):.1f}s "
|
|
267
|
+
f"(grandchild Chrome may have survived; check `ps aux | grep -i chrome`). "
|
|
268
|
+
f"If another Chrome instance owns {user_data_dir}, remove "
|
|
269
|
+
f"`SingletonLock` from that dir or pass a different `--profile`."
|
|
270
|
+
)
|
|
271
|
+
reasons.append(f"DevToolsActivePort never appeared in {user_data_dir}")
|
|
272
|
+
if fallback_url is not None:
|
|
273
|
+
suffix = f" (last HTTP error: {last_http_err})" if last_http_err else ""
|
|
274
|
+
reasons.append(f"and {fallback_url} did not become reachable{suffix}")
|
|
275
|
+
raise Unavailable(
|
|
276
|
+
f"launch-chrome: Chrome not ready after {timeout}s — "
|
|
277
|
+
+ "; ".join(reasons)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _truthy_env(name: str) -> bool:
|
|
282
|
+
"""Common truthy parser for env-var flags. Recognizes 1/true/yes/on/y
|
|
283
|
+
(case-insensitive); empty string and unset are False. Matches the
|
|
284
|
+
informal convention most CLIs use — REVIEW.md F-9 #11 found we
|
|
285
|
+
previously only accepted `"1"`/`"true"`/`"True"`, silently rejecting
|
|
286
|
+
`"yes"` / `"on"` / `"TRUE"`."""
|
|
287
|
+
return os.environ.get(name, "").strip().lower() in {
|
|
288
|
+
"1", "true", "yes", "on", "y",
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _check_not_default_profile(user_data_dir: Path, *, allow: bool) -> None:
|
|
293
|
+
"""Refuse if `user_data_dir` is the OS-default Chrome / Edge / Brave / Arc
|
|
294
|
+
profile root. The platforms table is the source of truth — we resolve both
|
|
295
|
+
sides to absolute paths and compare with `os.path.samefile()` if both
|
|
296
|
+
exist, then fall back to string-equality on the resolved Path.
|
|
297
|
+
|
|
298
|
+
Raises `UserError` when the dir matches a default-profile location and
|
|
299
|
+
`allow=False`. No-op otherwise.
|
|
300
|
+
"""
|
|
301
|
+
try:
|
|
302
|
+
target = user_data_dir.expanduser().resolve(strict=False)
|
|
303
|
+
except (OSError, RuntimeError):
|
|
304
|
+
target = user_data_dir
|
|
305
|
+
target_str = str(target)
|
|
306
|
+
for default in profile_paths():
|
|
307
|
+
try:
|
|
308
|
+
d = default.expanduser().resolve(strict=False)
|
|
309
|
+
except (OSError, RuntimeError):
|
|
310
|
+
d = default
|
|
311
|
+
if str(d) == target_str:
|
|
312
|
+
if allow:
|
|
313
|
+
return
|
|
314
|
+
raise UserError(
|
|
315
|
+
f"refusing to launch-chrome against the user's default profile "
|
|
316
|
+
f"({target_str}). Chrome will be permanently tainted with "
|
|
317
|
+
f"--remote-debugging-port (every ws upgrade triggers an "
|
|
318
|
+
f"'Allow remote debugging?' popup; the LISTEN socket persists "
|
|
319
|
+
f"across the Chrome process's lifetime). Use a different "
|
|
320
|
+
f"`--profile <isolated_name>` or `--tmp` instead. If you "
|
|
321
|
+
f"truly know what you're doing, set "
|
|
322
|
+
f"`BD_LAUNCH_CHROME_ALLOW_DEFAULT_PROFILE=1` — but note this "
|
|
323
|
+
f"may permanently expose your daily Chrome to CDP popup "
|
|
324
|
+
f"hazard (see chrome-popup-accumulation-bug memory).")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _allocate_data_dir(profile: str, *, persistent: bool) -> Path:
|
|
328
|
+
if persistent:
|
|
329
|
+
return cache_dir() / "profiles" / profile
|
|
330
|
+
# --tmp: a fresh per-launch dir, NOT auto-cleaned (spec §5.5 step 2). User
|
|
331
|
+
# cleans up by hand to avoid the race between Chrome shutdown writeback and
|
|
332
|
+
# our rm -rf.
|
|
333
|
+
return Path(tempfile.mkdtemp(prefix=f"browserwright-daemon-{profile}-"))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _spawn_kwargs() -> dict:
|
|
337
|
+
"""Detach the spawn from this terminal — mirrors browser-harness
|
|
338
|
+
`_ipc.py:68-76` `spawn_kwargs()`.
|
|
339
|
+
"""
|
|
340
|
+
if platform.system() == "Windows":
|
|
341
|
+
return {
|
|
342
|
+
"creationflags": (
|
|
343
|
+
subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
|
344
|
+
| subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined]
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
return {"start_new_session": True}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class _silent:
|
|
351
|
+
"""Context manager that swallows OSErrors during cleanup."""
|
|
352
|
+
def __enter__(self): return self
|
|
353
|
+
def __exit__(self, exc_type, exc, tb): return exc_type is not None and issubclass(exc_type, OSError)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Observability (v0.5): metrics counters + structured JSON logging.
|
|
2
|
+
|
|
3
|
+
Spec §7 v0.5 line 2 — "observability / metrics / structured logging".
|
|
4
|
+
|
|
5
|
+
Three pieces:
|
|
6
|
+
|
|
7
|
+
1. **Counters** (`Metrics`) — bucketed integer counters incremented inline
|
|
8
|
+
at hot-path call sites (client connect, upstream open, pre-open buffer
|
|
9
|
+
overflow, etc.). Pure dataclass; reads / writes don't lock because
|
|
10
|
+
asyncio gives us single-threaded mutation guarantees within the daemon
|
|
11
|
+
process.
|
|
12
|
+
|
|
13
|
+
2. **JSON log formatter** — opt-in via `BD_LOG_JSON=1`. Emits one JSON
|
|
14
|
+
object per log record, schema:
|
|
15
|
+
|
|
16
|
+
{"ts": "...", "level": "...", "logger": "...", "msg": "...",
|
|
17
|
+
"extra": {...optional structured kwargs...}}
|
|
18
|
+
|
|
19
|
+
The default human formatter stays unchanged.
|
|
20
|
+
|
|
21
|
+
3. **`stats` snapshot** — `snapshot()` returns a dict-of-dicts the
|
|
22
|
+
`browserwright-daemon stats` CLI subcommand serializes to JSON. Used for
|
|
23
|
+
external monitoring (`watch -n 5 'browserwright-daemon stats --json'`) and
|
|
24
|
+
in tests to assert hot paths actually incremented their counters.
|
|
25
|
+
|
|
26
|
+
Design constraints:
|
|
27
|
+
- No external metrics deps (prometheus_client, opentelemetry). The daemon
|
|
28
|
+
is supposed to be lightweight (§8.5 "no logging framework").
|
|
29
|
+
- Counters are coarse on purpose — every counter has a clear hot-path
|
|
30
|
+
call site. We don't try to time-bucket / histogram / export over the
|
|
31
|
+
wire. That's all v0.6+ territory.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import logging
|
|
37
|
+
import os
|
|
38
|
+
import sys
|
|
39
|
+
import time
|
|
40
|
+
from dataclasses import dataclass, field, asdict
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---- counters --------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Metrics:
|
|
48
|
+
"""All daemon counters live here. One instance per daemon process,
|
|
49
|
+
accessed via `metrics()` singleton.
|
|
50
|
+
|
|
51
|
+
Naming convention: `<area>_<event>` (snake_case). Areas are stable —
|
|
52
|
+
`client`, `upstream`, `proxy`, `auth` (four groups; v0.5.3 F-14 dropped
|
|
53
|
+
the stale `relay_*` mention from this docstring — relay activity is
|
|
54
|
+
counted under `proxy_*` / `upstream_*` instead). Adding a counter is a
|
|
55
|
+
minor version bump for the `stats --json` schema; renaming one is
|
|
56
|
+
major.
|
|
57
|
+
"""
|
|
58
|
+
started_at: float = field(default_factory=time.time)
|
|
59
|
+
|
|
60
|
+
# ---- client (downstream skill connections) ----
|
|
61
|
+
client_connected_total: int = 0
|
|
62
|
+
client_disconnected_total: int = 0
|
|
63
|
+
client_frame_received_total: int = 0
|
|
64
|
+
|
|
65
|
+
# ---- upstream (Chrome / cloud / relay) ----
|
|
66
|
+
upstream_open_attempts_total: int = 0
|
|
67
|
+
upstream_open_succeeded_total: int = 0
|
|
68
|
+
upstream_open_failed_total: int = 0
|
|
69
|
+
upstream_closed_total: int = 0
|
|
70
|
+
upstream_frame_received_total: int = 0
|
|
71
|
+
upstream_frame_sent_total: int = 0
|
|
72
|
+
|
|
73
|
+
# ---- proxy (router level) ----
|
|
74
|
+
proxy_attach_succeeded_total: int = 0
|
|
75
|
+
proxy_attach_rejected_total: int = 0
|
|
76
|
+
proxy_pre_open_buffered_total: int = 0
|
|
77
|
+
proxy_pre_open_overflow_total: int = 0
|
|
78
|
+
proxy_pre_open_drained_total: int = 0
|
|
79
|
+
|
|
80
|
+
# ---- auth (v0.5 cloud backend) ----
|
|
81
|
+
auth_headers_resolved_total: int = 0
|
|
82
|
+
auth_resolution_failures_total: int = 0
|
|
83
|
+
|
|
84
|
+
def snapshot(self) -> dict:
|
|
85
|
+
"""Return a flat dict suitable for JSON serialization.
|
|
86
|
+
|
|
87
|
+
Includes `uptime_seconds` derived from `started_at`.
|
|
88
|
+
"""
|
|
89
|
+
d = asdict(self)
|
|
90
|
+
d["uptime_seconds"] = round(time.time() - self.started_at, 3)
|
|
91
|
+
return d
|
|
92
|
+
|
|
93
|
+
def reset(self) -> None:
|
|
94
|
+
"""Re-init every counter back to 0 + started_at to now. Mostly a
|
|
95
|
+
test seam — production daemons rotate by restart, not by reset."""
|
|
96
|
+
for k in list(self.__dataclass_fields__.keys()):
|
|
97
|
+
if k == "started_at":
|
|
98
|
+
self.started_at = time.time()
|
|
99
|
+
else:
|
|
100
|
+
setattr(self, k, 0)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_singleton: Metrics | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def metrics() -> Metrics:
|
|
107
|
+
"""Lazy singleton accessor. The first call creates the instance; every
|
|
108
|
+
subsequent call returns the same one."""
|
|
109
|
+
global _singleton
|
|
110
|
+
if _singleton is None:
|
|
111
|
+
_singleton = Metrics()
|
|
112
|
+
return _singleton
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def reset_metrics_for_test() -> None:
|
|
116
|
+
"""Test seam — wipes the singleton so each test starts at zero."""
|
|
117
|
+
global _singleton
|
|
118
|
+
_singleton = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---- JSON log formatter ---------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class JSONLogFormatter(logging.Formatter):
|
|
125
|
+
"""Emit one JSON object per log record. Keeps log lines greppable by
|
|
126
|
+
field (`jq '.msg'`) and friendly to log aggregators.
|
|
127
|
+
|
|
128
|
+
Schema (stable in v0.5):
|
|
129
|
+
ts: ISO-8601 UTC
|
|
130
|
+
level: uppercase level name
|
|
131
|
+
logger: logger name (e.g. "browserwright.daemon.server.proxy")
|
|
132
|
+
msg: formatted message
|
|
133
|
+
extra: any non-standard `record.__dict__` entries the caller added
|
|
134
|
+
via `logger.info("...", extra={"client_id": 7})`
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
_STANDARD = {
|
|
138
|
+
"name", "msg", "args", "levelname", "levelno", "pathname",
|
|
139
|
+
"filename", "module", "exc_info", "exc_text", "stack_info",
|
|
140
|
+
"lineno", "funcName", "created", "msecs", "relativeCreated",
|
|
141
|
+
"thread", "threadName", "processName", "process", "asctime",
|
|
142
|
+
"message",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
146
|
+
payload: dict = {
|
|
147
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(record.created)),
|
|
148
|
+
"level": record.levelname,
|
|
149
|
+
"logger": record.name,
|
|
150
|
+
"msg": record.getMessage(),
|
|
151
|
+
}
|
|
152
|
+
extra = {k: v for k, v in record.__dict__.items()
|
|
153
|
+
if k not in self._STANDARD and not k.startswith("_")}
|
|
154
|
+
if extra:
|
|
155
|
+
payload["extra"] = extra
|
|
156
|
+
if record.exc_info:
|
|
157
|
+
payload["exc_info"] = self.formatException(record.exc_info)
|
|
158
|
+
return json.dumps(payload, default=str, ensure_ascii=False)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def install_json_logging_if_requested(stream=None) -> bool:
|
|
162
|
+
"""If `BD_LOG_JSON=1`, replace stderr / file handlers' formatter with
|
|
163
|
+
`JSONLogFormatter`. Idempotent. Returns True iff anything changed.
|
|
164
|
+
|
|
165
|
+
`stream` defaults to `sys.stderr` (the daemon's normal log channel).
|
|
166
|
+
Callers can override for tests.
|
|
167
|
+
"""
|
|
168
|
+
if os.environ.get("BD_LOG_JSON", "") not in ("1", "true", "True"):
|
|
169
|
+
return False
|
|
170
|
+
stream = stream or sys.stderr
|
|
171
|
+
formatter = JSONLogFormatter()
|
|
172
|
+
root = logging.getLogger()
|
|
173
|
+
# If no handlers, create one targeting the requested stream.
|
|
174
|
+
if not root.handlers:
|
|
175
|
+
h = logging.StreamHandler(stream)
|
|
176
|
+
h.setFormatter(formatter)
|
|
177
|
+
root.addHandler(h)
|
|
178
|
+
else:
|
|
179
|
+
for h in root.handlers:
|
|
180
|
+
h.setFormatter(formatter)
|
|
181
|
+
return True
|