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,150 @@
1
+ """Parse Tampermonkey-style ``==UserScript==`` headers into structured records.
2
+
3
+ v1 capability surface is plain page JS (no GM_* APIs); unsupported metadata
4
+ directives are collected as warnings rather than rejected, so existing
5
+ userscripts paste in without crashing.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import re
11
+ from dataclasses import dataclass, field
12
+
13
+ DEFAULT_NAMESPACE = "bd.userscripts"
14
+ _RUN_AT = {
15
+ "document-start": "document_start",
16
+ "document-end": "document_end",
17
+ "document-idle": "document_idle",
18
+ "document_start": "document_start",
19
+ "document_end": "document_end",
20
+ "document_idle": "document_idle",
21
+ }
22
+ _SUPPORTED = {
23
+ "name",
24
+ "namespace",
25
+ "match",
26
+ "include",
27
+ "exclude",
28
+ "run-at",
29
+ "version",
30
+ "description",
31
+ }
32
+ _HEADER_RE = re.compile(
33
+ r"//\s*==UserScript==\s*\n(.*?)//\s*==/UserScript==", re.DOTALL)
34
+ _LINE_RE = re.compile(r"//\s*@(\S+)\s+(.*?)\s*$")
35
+
36
+ # Chrome match-pattern grammar: ``<scheme>://<host><path>`` (or ``<all_urls>``).
37
+ # Validating here lets the daemon reject typos loudly instead of shipping a
38
+ # pattern that makes ``chrome.userScripts.register`` reject the whole batch on
39
+ # the extension side (which would silently disable every resident script).
40
+ _MATCH_PATTERN_RE = re.compile(
41
+ r"^(\*|https?|file|ftp|wss?)://(\*|(\*\.)?[^/*]+)?(/.*)$"
42
+ )
43
+
44
+
45
+ def _is_valid_match_pattern(pattern: str) -> bool:
46
+ return pattern == "<all_urls>" or bool(_MATCH_PATTERN_RE.match(pattern))
47
+
48
+
49
+ class UserscriptParseError(ValueError):
50
+ """Raised when a userscript has no header or no match pattern."""
51
+
52
+
53
+ @dataclass
54
+ class Userscript:
55
+ name: str
56
+ namespace: str
57
+ matches: list[str]
58
+ exclude_matches: list[str]
59
+ run_at: str
60
+ version: str
61
+ description: str
62
+ code: str
63
+ warnings: list[str] = field(default_factory=list)
64
+
65
+ @property
66
+ def identity(self) -> str:
67
+ return f"{self.namespace}/{self.name}"
68
+
69
+ @property
70
+ def id(self) -> str:
71
+ return hashlib.sha1(self.identity.encode()).hexdigest()[:12]
72
+
73
+ def to_payload(self) -> dict:
74
+ return {
75
+ "id": self.id,
76
+ "identity": self.identity,
77
+ "name": self.name,
78
+ "namespace": self.namespace,
79
+ "matches": self.matches,
80
+ "excludeMatches": self.exclude_matches,
81
+ "runAt": self.run_at,
82
+ "version": self.version,
83
+ "description": self.description,
84
+ "code": self.code,
85
+ "warnings": self.warnings,
86
+ }
87
+
88
+
89
+ def parse_userscript(text: str) -> Userscript:
90
+ match = _HEADER_RE.search(text)
91
+ if not match:
92
+ raise UserscriptParseError("missing ==UserScript== metadata block")
93
+ block = match.group(1)
94
+ code = text[match.end():].lstrip("\n")
95
+
96
+ name = ""
97
+ namespace = ""
98
+ version = ""
99
+ description = ""
100
+ run_at = "document_idle"
101
+ matches: list[str] = []
102
+ excludes: list[str] = []
103
+ warnings: list[str] = []
104
+
105
+ for line in block.splitlines():
106
+ line_match = _LINE_RE.match(line.strip())
107
+ if not line_match:
108
+ continue
109
+ key, value = line_match.group(1).lower(), line_match.group(2).strip()
110
+ if key == "name":
111
+ name = value
112
+ elif key == "namespace":
113
+ namespace = value
114
+ elif key in ("match", "include"):
115
+ if _is_valid_match_pattern(value):
116
+ matches.append(value)
117
+ else:
118
+ warnings.append(
119
+ f"@{key} {value!r} is not a valid match pattern (ignored)")
120
+ elif key == "exclude":
121
+ if _is_valid_match_pattern(value):
122
+ excludes.append(value)
123
+ else:
124
+ warnings.append(
125
+ f"@exclude {value!r} is not a valid match pattern (ignored)")
126
+ elif key == "run-at":
127
+ run_at = _RUN_AT.get(value, "document_idle")
128
+ elif key == "version":
129
+ version = value
130
+ elif key == "description":
131
+ description = value
132
+ elif key not in _SUPPORTED:
133
+ warnings.append(f"@{key} not supported in v1 (ignored)")
134
+
135
+ if not name:
136
+ raise UserscriptParseError("@name is required")
137
+ if not matches:
138
+ raise UserscriptParseError("at least one @match/@include is required")
139
+
140
+ return Userscript(
141
+ name=name,
142
+ namespace=namespace or DEFAULT_NAMESPACE,
143
+ matches=matches,
144
+ exclude_matches=excludes,
145
+ run_at=run_at,
146
+ version=version,
147
+ description=description,
148
+ code=code,
149
+ warnings=warnings,
150
+ )
@@ -0,0 +1,213 @@
1
+ """site-skills index + simple ranking (spec §E)."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as _dt
5
+ import importlib.util
6
+ import json
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any, Optional
10
+
11
+ from .memory import _md
12
+ from .memory.site_mem import site_skills_root, site_skills_roots
13
+
14
+
15
+ def _bundled_root() -> Path:
16
+ return Path(__file__).resolve().parent / "site_skills_starter"
17
+
18
+
19
+ def _iter_site_dirs() -> list[Path]:
20
+ """Project → ``$BS_HOME`` → subscriptions → bundled, dedup by site name.
21
+
22
+ The order encodes the precedence promise: project workspace is always
23
+ king, then per-user, then explicit subscriptions (the user opted in to
24
+ them by ``browserwright sub add``), then the bundled starter set as
25
+ fallback.
26
+ """
27
+ from .subscriptions import iter_subscription_site_roots
28
+ roots = [*site_skills_roots(), *iter_subscription_site_roots(), _bundled_root()]
29
+ seen: set[str] = set()
30
+ out: list[Path] = []
31
+ for root in roots:
32
+ if not root.exists():
33
+ continue
34
+ for child in sorted(root.iterdir()):
35
+ if not child.is_dir():
36
+ continue
37
+ if child.name in seen:
38
+ continue
39
+ if not (child / "tasks").exists() and not (child / "memory.md").exists():
40
+ continue
41
+ seen.add(child.name)
42
+ out.append(child)
43
+ return out
44
+
45
+
46
+ def _load_task_meta(task_py: Path) -> dict[str, Any]:
47
+ spec = importlib.util.spec_from_file_location(task_py.stem, task_py)
48
+ if not spec or not spec.loader:
49
+ return {"name": task_py.stem}
50
+ mod = importlib.util.module_from_spec(spec)
51
+ try:
52
+ spec.loader.exec_module(mod)
53
+ except Exception:
54
+ return {"name": task_py.stem, "load_error": True}
55
+ return {
56
+ "name": task_py.stem,
57
+ "desc": (getattr(mod, "__doc__", "") or "").strip().splitlines()[:1][0]
58
+ if getattr(mod, "__doc__", "") else "",
59
+ "args": getattr(mod, "ARGS", {}),
60
+ "output": getattr(mod, "OUTPUT", "Any"),
61
+ "tags": getattr(mod, "TAGS", []),
62
+ "requires_login": bool(getattr(mod, "REQUIRES_LOGIN", False)),
63
+ "last_verified": getattr(mod, "LAST_VERIFIED", None),
64
+ "broken_since": getattr(mod, "BROKEN_SINCE", None),
65
+ "path": str(task_py.resolve()),
66
+ }
67
+
68
+
69
+ def _load_site_entry(site_dir: Path) -> dict[str, Any]:
70
+ fm = {}
71
+ if (site_dir / "memory.md").exists():
72
+ fm, _body = _md.parse_doc((site_dir / "memory.md").read_text(encoding="utf-8"))
73
+ desc = ""
74
+ if (site_dir / "SKILL.md").exists():
75
+ first = ((site_dir / "SKILL.md").read_text(encoding="utf-8")).strip().splitlines()
76
+ if first:
77
+ desc = first[0].lstrip("# ").strip()
78
+ tasks_dir = site_dir / "tasks"
79
+ tasks = []
80
+ if tasks_dir.exists():
81
+ for t in sorted(tasks_dir.glob("*.py")):
82
+ tasks.append(_load_task_meta(t))
83
+ return {
84
+ "site": site_dir.name,
85
+ "host_patterns": fm.get("host_patterns", []),
86
+ "aliases": fm.get("aliases", []),
87
+ "description_first_line": desc,
88
+ "tasks": tasks,
89
+ "path": str(site_dir.resolve()),
90
+ }
91
+
92
+
93
+ def rebuild_index() -> dict[str, Any]:
94
+ out = {
95
+ "version": 1,
96
+ "generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(),
97
+ "sites": [_load_site_entry(d) for d in _iter_site_dirs()],
98
+ }
99
+ target = site_skills_root() / "index.json"
100
+ target.parent.mkdir(parents=True, exist_ok=True)
101
+ target.write_text(json.dumps(out, indent=2, default=str), encoding="utf-8")
102
+ return out
103
+
104
+
105
+ def _load_index() -> dict[str, Any]:
106
+ target = site_skills_root() / "index.json"
107
+ if not target.exists():
108
+ return rebuild_index()
109
+ try:
110
+ return json.loads(target.read_text(encoding="utf-8"))
111
+ except json.JSONDecodeError:
112
+ return rebuild_index()
113
+
114
+
115
+ # ---- ranking ----------------------------------------------------------
116
+
117
+
118
+ def _tokens(s: str) -> set[str]:
119
+ return {p for p in re.split(r"[\s/_\-]+", (s or "").lower()) if p}
120
+
121
+
122
+ def _jaccard(a: set[str], b: set[str]) -> float:
123
+ if not a or not b:
124
+ return 0.0
125
+ return len(a & b) / len(a | b)
126
+
127
+
128
+ def _days_since(date_str: Optional[str]) -> int:
129
+ if not date_str:
130
+ return 9999
131
+ try:
132
+ d = _dt.date.fromisoformat(date_str[:10])
133
+ except ValueError:
134
+ return 9999
135
+ return (_dt.date.today() - d).days
136
+
137
+
138
+ def score(query: str, site_entry: dict, task: dict) -> float:
139
+ q = (query or "").lower()
140
+ s = 0.0
141
+ for alias in site_entry.get("aliases", []):
142
+ if alias and alias.lower() in q:
143
+ s += 1.0
144
+ break
145
+ for h in site_entry.get("host_patterns", []):
146
+ if h and h.split(".")[0] in q:
147
+ s += 0.5
148
+ break
149
+ s += 0.3 * _jaccard(_tokens(task.get("desc", "")), _tokens(query or ""))
150
+ for t in task.get("tags", []):
151
+ if t and t.lower() in q:
152
+ s += 0.2
153
+ if task.get("broken_since"):
154
+ s -= 0.5
155
+ if _days_since(task.get("last_verified")) < 30:
156
+ s += 0.1
157
+ return s
158
+
159
+
160
+ def list_tasks(*, site: Optional[str] = None, query: Optional[str] = None,
161
+ limit: int = 20) -> list[dict[str, Any]]:
162
+ index = _load_index()
163
+ out: list[dict[str, Any]] = []
164
+ for entry in index.get("sites", []):
165
+ if site and entry["site"] != site:
166
+ continue
167
+ for t in entry.get("tasks", []):
168
+ row = {
169
+ "site": entry["site"],
170
+ "name": t["name"],
171
+ "desc": t.get("desc", ""),
172
+ "args": t.get("args", {}),
173
+ "output": t.get("output", "Any"),
174
+ "tags": t.get("tags", []),
175
+ "requires_login": t.get("requires_login", False),
176
+ "last_verified": t.get("last_verified"),
177
+ "broken_since": t.get("broken_since"),
178
+ "path": t.get("path"),
179
+ }
180
+ if query:
181
+ row["match_score"] = round(score(query, entry, t), 3)
182
+ out.append(row)
183
+ if query:
184
+ out.sort(key=lambda r: r.get("match_score", 0), reverse=True)
185
+ return out[:limit]
186
+
187
+
188
+ def find_task_path(site: str, name: str) -> Path:
189
+ """Return absolute path to ``site-skills/<site>/tasks/<name>.py``,
190
+ consulting project → $BS_HOME → subscriptions → bundled in that order.
191
+ Raises ``FileNotFoundError``.
192
+
193
+ Site-name normalisation (Bug 1, v0.3.1): if the literal ``site`` arg
194
+ doesn't resolve, retry with ``host_stem(site)`` so the caller can pass
195
+ a raw URL or hostname (e.g. ``news.ycombinator.com`` from a CLI
196
+ invocation) and still hit the eTLD+1-named bundled directory
197
+ (``ycombinator.com``).
198
+ """
199
+ from .subscriptions import iter_subscription_site_roots
200
+ from .memory.site_mem import host_stem
201
+ roots = (*site_skills_roots(),
202
+ *iter_subscription_site_roots(),
203
+ _bundled_root())
204
+ candidates: list[str] = [site]
205
+ stem = host_stem(site)
206
+ if stem and stem != site:
207
+ candidates.append(stem)
208
+ for s in candidates:
209
+ for root in roots:
210
+ cand = root / s / "tasks" / f"{name}.py"
211
+ if cand.exists():
212
+ return cand
213
+ raise FileNotFoundError(f"{site}/{name}")
@@ -0,0 +1,177 @@
1
+ """Skill exception hierarchy (spec §A.4)."""
2
+
3
+
4
+ class BrowserwrightError(Exception):
5
+ """Root of every exception Skill itself raises.
6
+
7
+ Every error can carry a ``fix`` — a short, concrete next-action string
8
+ ("call X" / "run Y") so an agent reading the error has a recovery step,
9
+ not just a raw transport/protocol message. This generalizes the pattern
10
+ that ``NeedsUserConfirm.proposal`` established: errors are actionable.
11
+
12
+ Subclasses MAY set a class-level ``default_fix`` so a bare ``raise`` is
13
+ still actionable; an explicit ``fix=`` at the raise site overrides it.
14
+ """
15
+
16
+ exit_code = 3 # default: script raised
17
+ default_fix = ""
18
+
19
+ def __init__(self, *args, fix: str = ""):
20
+ super().__init__(*args)
21
+ # Explicit fix wins; otherwise fall back to the class default.
22
+ self.fix = fix or type(self).default_fix
23
+ if self.fix and args:
24
+ # Surface the next-action in __str__ so an agent that only logs
25
+ # the message still sees the recovery step.
26
+ self.args = (f"{args[0]} [fix: {self.fix}]",) + tuple(args[1:])
27
+
28
+
29
+ class PageLoadFailed(BrowserwrightError):
30
+ exit_code = 3
31
+ default_fix = (
32
+ "retry with new_tab(url) then wait_for_load(); if it persists, "
33
+ "check the URL and network with http_get(url)"
34
+ )
35
+
36
+ def __init__(self, url: str = "", reason: str = "", fix: str = ""):
37
+ self.url, self.reason = url, reason
38
+ super().__init__(f"page load failed: {url} ({reason})", fix=fix)
39
+
40
+
41
+ class ElementNotFound(BrowserwrightError):
42
+ exit_code = 3
43
+ default_fix = (
44
+ "capture_screenshot() to confirm the element is visible, then "
45
+ "click_at_xy(x, y); use snapshot() to list interactive elements"
46
+ )
47
+
48
+ def __init__(self, selector: str = "", timeout: float = 0.0, fix: str = ""):
49
+ self.selector, self.timeout = selector, timeout
50
+ super().__init__(f"element not found: {selector!r} after {timeout}s", fix=fix)
51
+
52
+
53
+ class AuthWall(BrowserwrightError):
54
+ exit_code = 4
55
+ default_fix = "stop and ask the user to log in; do not type credentials from a screenshot"
56
+
57
+ def __init__(self, url: str = "", signals=None, fix: str = ""):
58
+ self.url, self.signals = url, list(signals or [])
59
+ super().__init__(f"auth wall at {url}: {self.signals}", fix=fix)
60
+
61
+
62
+ class Captcha(BrowserwrightError):
63
+ exit_code = 5
64
+ default_fix = "stop and ask the user to solve the captcha; do not attempt to bypass it"
65
+
66
+ def __init__(self, kind: str = "unknown", url: str = "", fix: str = ""):
67
+ self.kind, self.url = kind, url
68
+ super().__init__(f"captcha ({kind}) at {url}", fix=fix)
69
+
70
+
71
+ class NetworkError(BrowserwrightError):
72
+ exit_code = 3
73
+ default_fix = "verify the URL and connectivity, then retry; check http_get(url) for static pages"
74
+
75
+ def __init__(self, url: str = "", status=None, fix: str = ""):
76
+ self.url, self.status = url, status
77
+ super().__init__(f"network error: {url} (status={status})", fix=fix)
78
+
79
+
80
+ class DaemonUnavailable(BrowserwrightError):
81
+ exit_code = 2
82
+ default_fix = (
83
+ "start the single global daemon: `browserwright-daemon serve` "
84
+ "(or run `browserwright doctor` to see what is missing)"
85
+ )
86
+
87
+ def __init__(self, detail: str = "", fix: str = ""):
88
+ self.detail = detail
89
+ super().__init__(f"daemon unavailable: {detail}", fix=fix)
90
+
91
+
92
+ class NoSession(BrowserwrightError):
93
+ """No explicit session provided. Refuse rather than silently sharing a browser."""
94
+
95
+ exit_code = 2
96
+ default_fix = (
97
+ "run `browserwright session new --backend=<extension|rdp> --name=SESSION_LABEL` "
98
+ "then run `browserwright -s <id> -e 'print(snapshot())'`"
99
+ )
100
+
101
+ def __init__(self, detail: str = "", fix: str = ""):
102
+ self.detail = detail
103
+ super().__init__(
104
+ "no session: run `browserwright session new --backend=<extension|rdp> "
105
+ "--name=SESSION_LABEL` first (use the `=` form; --name is a short "
106
+ "session label), then pass -s <id> on every execute call. "
107
+ + detail,
108
+ fix=fix,
109
+ )
110
+
111
+
112
+ class CDPError(BrowserwrightError):
113
+ exit_code = 3
114
+ default_fix = (
115
+ "if the message mentions an unknown method (-32601) the daemon is "
116
+ "likely stale — `browserwright-daemon stop` then re-run; otherwise check "
117
+ "the method name and params"
118
+ )
119
+
120
+ def __init__(self, method: str = "", params=None, cdp_message: str = "", fix: str = ""):
121
+ self.method, self.params, self.cdp_message = method, dict(params or {}), cdp_message
122
+ super().__init__(f"CDP {method} failed: {cdp_message}", fix=fix)
123
+
124
+
125
+ class NeedsUserConfirm(BrowserwrightError):
126
+ """Raised by remember_preference (and similar) when the agent must surface
127
+ a confirm prompt to the user before re-calling with confirm=False."""
128
+
129
+ exit_code = 1
130
+
131
+ def __init__(self, what: str = "", proposal=None, fix: str = ""):
132
+ self.what, self.proposal = what, proposal
133
+ super().__init__(
134
+ f"needs user confirm: {what}",
135
+ # The proposal IS the next-action; mirror it into fix so the
136
+ # generic envelope is uniform across every error type.
137
+ fix=fix or "surface the proposal to the user, then re-call with confirm=True",
138
+ )
139
+
140
+
141
+ def serialize(exc: BaseException) -> dict:
142
+ """Compact JSON-friendly representation for stderr / repl socket."""
143
+ out = {"type": type(exc).__name__, "msg": str(exc)}
144
+ for k in ("url", "selector", "timeout", "reason", "signals", "kind",
145
+ "status", "detail", "site", "task", "failed_check",
146
+ "method", "cdp_message", "what", "proposal", "fix"):
147
+ v = getattr(exc, k, None)
148
+ if v is not None and not isinstance(v, (type(None),)):
149
+ try:
150
+ import json as _json
151
+ _json.dumps(v) # ensure serializable
152
+ out[k] = v
153
+ except (TypeError, ValueError):
154
+ out[k] = repr(v)
155
+ return out
156
+
157
+
158
+ def playwright_error_fix(exc: BaseException) -> str:
159
+ """Best-effort recovery hint for raw Playwright exceptions.
160
+
161
+ This intentionally does not wrap or re-raise the original exception. It is
162
+ used at serialization boundaries so agent-authored ``try/except`` behavior
163
+ inside the executor stays native Playwright, while the surfaced error gains
164
+ a concrete next step.
165
+ """
166
+ msg = str(exc)
167
+ lower = msg.lower()
168
+ exc_type = type(exc).__name__
169
+ if "frame detached" in lower or "target closed" in lower or "page closed" in lower:
170
+ return "call reset() to rebuild the browser connection, then re-snapshot and retry"
171
+ if "timeout" not in lower and exc_type != "TimeoutError":
172
+ return ""
173
+ if "locator" in lower or "click" in lower or "fill" in lower:
174
+ return "call snapshot() to confirm the target still exists, then re-snapshot and retry with the current ref"
175
+ if "goto" in lower or "navigation" in lower:
176
+ return "retry page.goto(url); Browserwright will use smart waiting, or verify the site with http_get(url)"
177
+ return "call snapshot() to inspect the current page state, then retry the action with the current ref"