harness-browser 0.1.1__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.
@@ -0,0 +1,18 @@
1
+ """harness-browser: AI-friendly browser automation via CDP."""
2
+
3
+ from harness_browser.mode import BrowserMode
4
+ from harness_browser.models import ActionMetrics, TabInfo, ToolResult
5
+ from harness_browser.session import BrowserSession
6
+ from harness_browser.settings import HarnessSettings, settings
7
+ from harness_browser.tool_interface import browser_tool
8
+
9
+ __all__ = [
10
+ "BrowserSession",
11
+ "browser_tool",
12
+ "BrowserMode",
13
+ "HarnessSettings",
14
+ "settings",
15
+ "ToolResult",
16
+ "ActionMetrics",
17
+ "TabInfo",
18
+ ]
File without changes
@@ -0,0 +1,228 @@
1
+ """Screenshot capture action.
2
+
3
+ Writes a PNG file to disk and returns its path. We deliberately do **not**
4
+ return the raw base64 payload to callers — base64 strings are large and
5
+ costly to push through agent toolchains, and the file path is what
6
+ downstream renderers (file_preview, dashboards, MCP clients) need anyway.
7
+
8
+ Output location precedence:
9
+
10
+ 1. Explicit ``path=`` argument (absolute → as-is, relative → under
11
+ ``settings.screenshots_dir``)
12
+ 2. ``settings.screenshots_dir / harness-<timestamp_ms>.png``
13
+
14
+ Optional flags:
15
+
16
+ - ``full_page=True`` — capture the entire scrollable page using
17
+ ``Page.getLayoutMetrics().cssContentSize`` to size the clip rectangle.
18
+ **Use sparingly.** Real-world pages scroll for many screens (10+ viewports
19
+ is typical), so ``full_page`` PNGs are huge and rarely useful for agents
20
+ trying to locate one element — prefer the viewport default plus targeted
21
+ scrolling. ``full_page`` is appropriate only when the caller explicitly
22
+ needs a single-image archival/regression capture.
23
+ - ``element_ref="..."`` — clip to one element's bounding box. Mutually
24
+ exclusive with ``full_page`` (element_ref wins to keep behavior
25
+ predictable).
26
+
27
+ The action also enriches ``ToolResult.metadata`` with the page ``url`` and
28
+ ``title`` so the caller can surface them without an extra ``Runtime.evaluate``.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import base64
34
+ import json
35
+ import time
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ from harness_browser.cdp.client import CDPClient, CDPSessionError
40
+ from harness_browser.dom.refs import RefCache
41
+ from harness_browser.models import ActionMetrics, ToolResult
42
+ from harness_browser.settings import HarnessSettings
43
+ from harness_browser.settings import settings as _default_settings
44
+
45
+
46
+ def _resolve_path(
47
+ path: str | Path | None,
48
+ cfg: HarnessSettings,
49
+ ) -> Path:
50
+ """Compute the destination path for a screenshot."""
51
+ base = Path(cfg.screenshots_dir)
52
+ if path:
53
+ candidate = Path(path).expanduser()
54
+ if not candidate.is_absolute():
55
+ candidate = base / candidate
56
+ candidate.parent.mkdir(parents=True, exist_ok=True)
57
+ return candidate
58
+ base.mkdir(parents=True, exist_ok=True)
59
+ return base / f"harness-{int(time.time() * 1000)}.png"
60
+
61
+
62
+ async def _page_context(client: CDPClient) -> dict[str, str]:
63
+ """Return ``{"url": ..., "title": ...}`` via a single Runtime.evaluate.
64
+
65
+ Failures are non-fatal — we just return empty strings.
66
+ """
67
+ try:
68
+ info = await client.send(
69
+ "Runtime.evaluate",
70
+ {
71
+ "expression": (
72
+ "JSON.stringify({url: location.href, title: document.title})"
73
+ ),
74
+ "returnByValue": True,
75
+ },
76
+ )
77
+ raw = info.get("result", {}).get("value", "{}")
78
+ data = json.loads(raw) if isinstance(raw, str) else {}
79
+ return {
80
+ "url": str(data.get("url", "")),
81
+ "title": str(data.get("title", "")),
82
+ }
83
+ except Exception:
84
+ return {"url": "", "title": ""}
85
+
86
+
87
+ async def screenshot(
88
+ client: CDPClient,
89
+ ref_cache: RefCache,
90
+ crop: bool = False,
91
+ element_ref: str | None = None,
92
+ full_page: bool = False,
93
+ path: str | Path | None = None,
94
+ settings: HarnessSettings | None = None,
95
+ ) -> ToolResult:
96
+ """Capture a screenshot, write it to disk, and return its path.
97
+
98
+ Args:
99
+ client: Connected CDP client for the active page.
100
+ ref_cache: Ref cache for ``element_ref`` lookup.
101
+ crop: Reserved for future use; currently has no independent effect.
102
+ element_ref: If set, crop to the bounding box of this element ref.
103
+ Takes precedence over ``full_page``.
104
+ full_page: If True (and ``element_ref`` is unset), capture the full
105
+ scrollable page, not just the visible viewport. **Default False
106
+ — almost always leave it that way.** Modern landing pages
107
+ routinely scroll for 10+ viewports; a ``full_page`` PNG is
108
+ then several MB of mostly-empty visual noise that drowns
109
+ agents in tokens. Reserve ``full_page=True`` for archival /
110
+ visual-regression captures where a single image is the
111
+ explicit deliverable.
112
+ path: Optional output path. Absolute paths used verbatim; relative
113
+ paths resolve under ``settings.screenshots_dir``.
114
+ settings: :class:`HarnessSettings` override. Defaults to the module
115
+ singleton (env-driven).
116
+
117
+ Returns:
118
+ :class:`ToolResult` whose ``content`` is the absolute path string of
119
+ the saved PNG. ``metadata`` carries ``{"url", "title", "full_page",
120
+ "width", "height", "size_kb", "path"}`` so callers can render context
121
+ without a follow-up CDP call.
122
+ """
123
+ cfg = settings or _default_settings
124
+ start = time.monotonic()
125
+ params: dict[str, Any] = {"format": "png"}
126
+
127
+ width = 0
128
+ height = 0
129
+
130
+ if element_ref is not None:
131
+ node_id = ref_cache.lookup(element_ref)
132
+ if node_id is None:
133
+ raise CDPSessionError(
134
+ f"Ref '{element_ref}' not found. Call dom_tree() first."
135
+ )
136
+ box = await client.send("DOM.getBoxModel", {"nodeId": node_id})
137
+ model = box.get("model", {})
138
+ content_pts = model.get("content", [0, 0, 100, 0, 100, 100, 0, 100])
139
+ x = min(content_pts[0::2])
140
+ y = min(content_pts[1::2])
141
+ width = max(content_pts[0::2]) - x
142
+ height = max(content_pts[1::2]) - y
143
+ params["clip"] = {
144
+ "x": x,
145
+ "y": y,
146
+ "width": width,
147
+ "height": height,
148
+ "scale": 1,
149
+ }
150
+ elif full_page:
151
+ # Use CSS content size so the clip captures the full scrollable page,
152
+ # not just the current viewport. ``captureBeyondViewport`` lets Chrome
153
+ # render outside the visible area into the PNG.
154
+ metrics = await client.send("Page.getLayoutMetrics", {})
155
+ # Newer CDP exposes cssContentSize / cssVisualViewport; fall back to
156
+ # the older snake-cased fields if needed.
157
+ css = metrics.get("cssContentSize") or metrics.get("contentSize") or {}
158
+ width = int(css.get("width", 0)) or 0
159
+ height = int(css.get("height", 0)) or 0
160
+ if width > 0 and height > 0:
161
+ params["clip"] = {
162
+ "x": 0,
163
+ "y": 0,
164
+ "width": width,
165
+ "height": height,
166
+ "scale": 1,
167
+ }
168
+ params["captureBeyondViewport"] = True
169
+
170
+ result = await client.send("Page.captureScreenshot", params)
171
+ raw_b64: str = result.get("data", "")
172
+ if not raw_b64:
173
+ return ToolResult(
174
+ success=False,
175
+ content="",
176
+ error="Page.captureScreenshot returned empty data",
177
+ metrics=ActionMetrics(
178
+ action="screenshot",
179
+ duration_ms=int((time.monotonic() - start) * 1000),
180
+ dom_nodes_scanned=0,
181
+ estimated_tokens=0,
182
+ ),
183
+ )
184
+
185
+ img_bytes = base64.b64decode(raw_b64)
186
+ target = _resolve_path(path, cfg)
187
+ target.write_bytes(img_bytes)
188
+
189
+ # Default (viewport) capture leaves width/height at 0 above. Try to fill
190
+ # them in from the visual viewport so callers can render a meaningful
191
+ # size badge without a follow-up CDP call. Failures are non-fatal.
192
+ if width == 0 and height == 0:
193
+ try:
194
+ metrics_resp = await client.send("Page.getLayoutMetrics", {})
195
+ vp = (
196
+ metrics_resp.get("cssVisualViewport")
197
+ or metrics_resp.get("visualViewport")
198
+ or {}
199
+ )
200
+ width = int(vp.get("clientWidth") or vp.get("width") or 0)
201
+ height = int(vp.get("clientHeight") or vp.get("height") or 0)
202
+ except Exception:
203
+ pass
204
+
205
+ ctx = await _page_context(client)
206
+ metadata: dict[str, object] = {
207
+ "path": str(target),
208
+ "url": ctx["url"],
209
+ "title": ctx["title"],
210
+ "full_page": bool(full_page and element_ref is None),
211
+ "element_ref": element_ref,
212
+ "width": width,
213
+ "height": height,
214
+ "size_kb": len(img_bytes) // 1024,
215
+ }
216
+
217
+ return ToolResult(
218
+ success=True,
219
+ content=str(target),
220
+ metrics=ActionMetrics(
221
+ action="screenshot",
222
+ duration_ms=int((time.monotonic() - start) * 1000),
223
+ dom_nodes_scanned=0,
224
+ estimated_tokens=0,
225
+ screenshot_size_kb=len(img_bytes) // 1024,
226
+ ),
227
+ metadata=metadata,
228
+ )
@@ -0,0 +1,163 @@
1
+ """Interaction actions: click, type_text, scroll, hover."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ from harness_browser.cdp.client import CDPClient, CDPSessionError
8
+ from harness_browser.dom.refs import RefCache
9
+ from harness_browser.models import ActionMetrics, ToolResult
10
+
11
+
12
+ def _metrics(action: str, start: float) -> ActionMetrics:
13
+ return ActionMetrics(
14
+ action=action,
15
+ duration_ms=int((time.monotonic() - start) * 1000),
16
+ dom_nodes_scanned=1,
17
+ estimated_tokens=10,
18
+ )
19
+
20
+
21
+ async def _get_center(client: CDPClient, node_id: int) -> tuple[float, float]:
22
+ """Get the center coordinates of a DOM node."""
23
+ box = await client.send("DOM.getBoxModel", {"nodeId": node_id})
24
+ model = box.get("model", {})
25
+ content = model.get("content", [0, 0, 0, 0, 0, 0, 0, 0])
26
+ # content is [x0,y0, x1,y1, x2,y2, x3,y3] (clockwise from top-left)
27
+ cx = (content[0] + content[4]) / 2
28
+ cy = (content[1] + content[5]) / 2
29
+ return cx, cy
30
+
31
+
32
+ async def _resolve_coords(
33
+ client: CDPClient,
34
+ ref_cache: RefCache,
35
+ ref: str | None,
36
+ selector: str | None,
37
+ x: int | None,
38
+ y: int | None,
39
+ ) -> tuple[float, float, str]:
40
+ """Resolve click target to (x, y, description)."""
41
+ if ref is not None:
42
+ node_id = ref_cache.lookup(ref)
43
+ if node_id is None:
44
+ raise CDPSessionError(f"Ref '{ref}' not found. Call dom_tree() first.")
45
+ cx, cy = await _get_center(client, node_id)
46
+ return cx, cy, f"ref={ref}"
47
+ if selector is not None:
48
+ doc = await client.send("DOM.getDocument", {"depth": 0})
49
+ root_id = doc["root"]["nodeId"]
50
+ result = await client.send(
51
+ "DOM.querySelector", {"nodeId": root_id, "selector": selector}
52
+ )
53
+ node_id = result.get("nodeId", 0)
54
+ if not node_id:
55
+ raise CDPSessionError(f"Selector '{selector}' matched no elements.")
56
+ cx, cy = await _get_center(client, node_id)
57
+ return cx, cy, f"selector={selector}"
58
+ if x is not None and y is not None:
59
+ return float(x), float(y), f"({x}, {y})"
60
+ raise ValueError("Must provide ref, selector, or (x, y)")
61
+
62
+
63
+ async def click(
64
+ client: CDPClient,
65
+ ref_cache: RefCache,
66
+ ref: str | None = None,
67
+ selector: str | None = None,
68
+ x: int | None = None,
69
+ y: int | None = None,
70
+ ) -> ToolResult:
71
+ """Click an element identified by ref, selector, or coordinates."""
72
+ start = time.monotonic()
73
+ cx, cy, desc = await _resolve_coords(client, ref_cache, ref, selector, x, y)
74
+ for event_type in ("mousePressed", "mouseReleased"):
75
+ await client.send(
76
+ "Input.dispatchMouseEvent",
77
+ {
78
+ "type": event_type,
79
+ "x": cx,
80
+ "y": cy,
81
+ "button": "left",
82
+ "clickCount": 1,
83
+ },
84
+ )
85
+ return ToolResult(
86
+ success=True,
87
+ content=f"Clicked {desc} at ({cx:.0f}, {cy:.0f})",
88
+ metrics=_metrics("click", start),
89
+ )
90
+
91
+
92
+ async def type_text(
93
+ client: CDPClient,
94
+ ref_cache: RefCache,
95
+ text: str,
96
+ ref: str | None = None,
97
+ ) -> ToolResult:
98
+ """Type text, optionally clicking a target element first."""
99
+ start = time.monotonic()
100
+ if ref is not None:
101
+ await click(client, ref_cache, ref=ref)
102
+ for char in text:
103
+ await client.send("Input.dispatchKeyEvent", {"type": "char", "text": char})
104
+ return ToolResult(
105
+ success=True,
106
+ content=f"Typed {len(text)} character(s)",
107
+ metrics=_metrics("type", start),
108
+ )
109
+
110
+
111
+ async def scroll(
112
+ client: CDPClient,
113
+ ref_cache: RefCache,
114
+ direction: str = "down",
115
+ amount: int = 300,
116
+ ) -> ToolResult:
117
+ """Scroll the page in a direction by pixel amount."""
118
+ start = time.monotonic()
119
+ delta_y = amount if direction == "down" else -amount
120
+ delta_x = (
121
+ amount if direction == "right" else (-amount if direction == "left" else 0)
122
+ )
123
+ await client.send(
124
+ "Input.dispatchMouseEvent",
125
+ {
126
+ "type": "mouseWheel",
127
+ "x": 400,
128
+ "y": 300,
129
+ "deltaX": delta_x,
130
+ "deltaY": delta_y,
131
+ },
132
+ )
133
+ return ToolResult(
134
+ success=True,
135
+ content=f"Scrolled {direction} by {amount}px",
136
+ metrics=_metrics("scroll", start),
137
+ )
138
+
139
+
140
+ async def hover(
141
+ client: CDPClient,
142
+ ref_cache: RefCache,
143
+ ref: str,
144
+ ) -> ToolResult:
145
+ """Move the mouse over an element."""
146
+ start = time.monotonic()
147
+ node_id = ref_cache.lookup(ref)
148
+ if node_id is None:
149
+ raise CDPSessionError(f"Ref '{ref}' not found. Call dom_tree() first.")
150
+ cx, cy = await _get_center(client, node_id)
151
+ await client.send(
152
+ "Input.dispatchMouseEvent",
153
+ {
154
+ "type": "mouseMoved",
155
+ "x": cx,
156
+ "y": cy,
157
+ },
158
+ )
159
+ return ToolResult(
160
+ success=True,
161
+ content=f"Hovered over ref={ref} at ({cx:.0f}, {cy:.0f})",
162
+ metrics=_metrics("hover", start),
163
+ )
@@ -0,0 +1,59 @@
1
+ """JavaScript evaluation action."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+
8
+ from harness_browser.cdp.client import CDPClient
9
+ from harness_browser.models import ActionMetrics, ToolResult
10
+
11
+
12
+ async def eval_js(client: CDPClient, expression: str) -> ToolResult:
13
+ """
14
+ Execute a JavaScript expression in the page context.
15
+
16
+ Returns the serialized result as a JSON string in content.
17
+ """
18
+ start = time.monotonic()
19
+ result = await client.send(
20
+ "Runtime.evaluate",
21
+ {
22
+ "expression": expression,
23
+ "returnByValue": True,
24
+ "awaitPromise": True,
25
+ },
26
+ )
27
+ value = result.get("result", {})
28
+ if value.get("subtype") == "error":
29
+ description = value.get("description", "Unknown JS error")
30
+ return ToolResult(
31
+ success=False,
32
+ content="",
33
+ error=description,
34
+ metrics=ActionMetrics(
35
+ action="eval_js",
36
+ duration_ms=int((time.monotonic() - start) * 1000),
37
+ dom_nodes_scanned=0,
38
+ estimated_tokens=0,
39
+ ),
40
+ )
41
+ raw = value.get("value")
42
+ content: str | dict[str, object]
43
+ if isinstance(raw, dict):
44
+ content = raw
45
+ elif isinstance(raw, list):
46
+ content = json.dumps(raw, ensure_ascii=False)
47
+ else:
48
+ content = json.dumps(raw, ensure_ascii=False) if raw is not None else "null"
49
+ tokens = len(str(content)) // 4
50
+ return ToolResult(
51
+ success=True,
52
+ content=content,
53
+ metrics=ActionMetrics(
54
+ action="eval_js",
55
+ duration_ms=int((time.monotonic() - start) * 1000),
56
+ dom_nodes_scanned=0,
57
+ estimated_tokens=tokens,
58
+ ),
59
+ )
@@ -0,0 +1,76 @@
1
+ """Navigation actions: navigate, go_back, go_forward, reload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Any
8
+
9
+ from harness_browser.cdp.client import CDPClient
10
+ from harness_browser.dom.refs import RefCache
11
+ from harness_browser.models import ActionMetrics, ToolResult
12
+
13
+
14
+ def _metrics(action: str, start: float) -> ActionMetrics:
15
+ return ActionMetrics(
16
+ action=action,
17
+ duration_ms=int((time.monotonic() - start) * 1000),
18
+ dom_nodes_scanned=0,
19
+ estimated_tokens=20,
20
+ )
21
+
22
+
23
+ async def navigate(client: CDPClient, ref_cache: RefCache, url: str) -> ToolResult:
24
+ """Navigate the page to a URL and wait for load."""
25
+ start = time.monotonic()
26
+ ref_cache.invalidate()
27
+ result = await client.send("Page.navigate", {"url": url})
28
+ load_event: asyncio.Future[None] = asyncio.get_event_loop().create_future()
29
+
30
+ def on_load(_params: dict[str, Any]) -> None:
31
+ if not load_event.done():
32
+ load_event.set_result(None)
33
+
34
+ client.on("Page.loadEventFired", on_load)
35
+ try:
36
+ await asyncio.wait_for(load_event, timeout=30.0)
37
+ except asyncio.TimeoutError:
38
+ pass
39
+ finally:
40
+ client.off("Page.loadEventFired", on_load)
41
+
42
+ _ = result.get("frameId", url)
43
+ content = f"Navigated to {url}"
44
+ return ToolResult(
45
+ success=True, content=content, metrics=_metrics("navigate", start)
46
+ )
47
+
48
+
49
+ async def go_back(client: CDPClient, ref_cache: RefCache) -> ToolResult:
50
+ """Navigate back in browser history."""
51
+ start = time.monotonic()
52
+ ref_cache.invalidate()
53
+ await client.send("Runtime.evaluate", {"expression": "history.back()"})
54
+ return ToolResult(
55
+ success=True, content="Navigated back", metrics=_metrics("go_back", start)
56
+ )
57
+
58
+
59
+ async def go_forward(client: CDPClient, ref_cache: RefCache) -> ToolResult:
60
+ """Navigate forward in browser history."""
61
+ start = time.monotonic()
62
+ ref_cache.invalidate()
63
+ await client.send("Runtime.evaluate", {"expression": "history.forward()"})
64
+ return ToolResult(
65
+ success=True, content="Navigated forward", metrics=_metrics("go_forward", start)
66
+ )
67
+
68
+
69
+ async def reload(client: CDPClient, ref_cache: RefCache) -> ToolResult:
70
+ """Reload the current page."""
71
+ start = time.monotonic()
72
+ ref_cache.invalidate()
73
+ await client.send("Page.reload", {"ignoreCache": False})
74
+ return ToolResult(
75
+ success=True, content="Page reloaded", metrics=_metrics("reload", start)
76
+ )
File without changes
@@ -0,0 +1,135 @@
1
+ """Pure asyncio Chrome DevTools Protocol WebSocket client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+
11
+ import websockets
12
+ from websockets.asyncio.client import ClientConnection
13
+
14
+ from harness_browser.settings import settings as _settings
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class CDPSessionError(Exception):
20
+ """Raised when a CDP command fails or the session is invalid."""
21
+
22
+
23
+ class CDPClient:
24
+ """
25
+ Manages a single CDP WebSocket connection to one browser page.
26
+
27
+ Usage::
28
+
29
+ client = CDPClient()
30
+ await client.connect("ws://localhost:9222/devtools/page/ABC")
31
+ result = await client.send("Page.navigate", {"url": "https://example.com"})
32
+ await client.close()
33
+ """
34
+
35
+ def __init__(self, timeout: float | None = None) -> None:
36
+ self._timeout = timeout if timeout is not None else _settings.cdp_timeout
37
+ self._ws: ClientConnection | None = None
38
+ self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
39
+ self._listeners: dict[str, list[Callable[..., Any]]] = {}
40
+ self._recv_task: asyncio.Task[None] | None = None
41
+ self._id = 0
42
+
43
+ async def connect(self, ws_url: str) -> None:
44
+ """Connect to a CDP WebSocket endpoint.
45
+
46
+ ``websockets`` defaults to a 1 MiB receive frame cap; CDP responses
47
+ for ``Page.captureScreenshot`` routinely exceed that on real-world
48
+ pages (a base64 PNG of a 1440×900 viewport easily lands at 1.3-2 MiB).
49
+ Hitting the cap closes the socket with code 1009 and the agent sees
50
+ a confusing ``message too big`` error mid-action. Honor
51
+ ``settings.cdp_max_message_size`` so screenshots up to that size
52
+ come through cleanly.
53
+ """
54
+ ws = await websockets.connect(
55
+ ws_url,
56
+ max_size=_settings.cdp_max_message_size,
57
+ )
58
+ self._ws = ws
59
+ self._recv_task = asyncio.create_task(self._recv_loop())
60
+ logger.debug("CDP connected to %s", ws_url)
61
+
62
+ async def send(
63
+ self, method: str, params: dict[str, Any] | None = None
64
+ ) -> dict[str, Any]:
65
+ """Send a CDP command and await its response."""
66
+ if self._ws is None:
67
+ raise CDPSessionError("Not connected")
68
+ self._id += 1
69
+ msg_id = self._id
70
+ payload = json.dumps({"id": msg_id, "method": method, "params": params or {}})
71
+ loop = asyncio.get_event_loop()
72
+ future: asyncio.Future[dict[str, Any]] = loop.create_future()
73
+ self._pending[msg_id] = future
74
+ await self._ws.send(payload)
75
+ try:
76
+ result = await asyncio.wait_for(future, timeout=self._timeout)
77
+ except asyncio.TimeoutError as exc:
78
+ self._pending.pop(msg_id, None)
79
+ raise CDPSessionError(f"Timeout waiting for response to {method}") from exc
80
+ if "error" in result:
81
+ raise CDPSessionError(f"CDP error for {method}: {result['error']}")
82
+ inner: dict[str, Any] = result.get("result", {})
83
+ return inner
84
+
85
+ async def enable_domain(self, domain: str) -> None:
86
+ """Enable a CDP domain (e.g. 'DOM', 'Page', 'Input')."""
87
+ await self.send(f"{domain}.enable")
88
+
89
+ def on(self, event: str, callback: Callable[..., Any]) -> None:
90
+ """Register a listener for a CDP event."""
91
+ self._listeners.setdefault(event, []).append(callback)
92
+
93
+ def off(self, event: str, callback: Callable[..., Any]) -> None:
94
+ """Unregister a listener."""
95
+ listeners = self._listeners.get(event, [])
96
+ if callback in listeners:
97
+ listeners.remove(callback)
98
+
99
+ async def close(self) -> None:
100
+ """Close the WebSocket connection."""
101
+ if self._recv_task:
102
+ self._recv_task.cancel()
103
+ try:
104
+ await self._recv_task
105
+ except asyncio.CancelledError:
106
+ pass
107
+ if self._ws:
108
+ await self._ws.close()
109
+ self._ws = None
110
+ logger.debug("CDP connection closed")
111
+
112
+ async def _recv_loop(self) -> None:
113
+ """Background task: read messages and dispatch to pending futures or
114
+ listeners."""
115
+ assert self._ws is not None
116
+ try:
117
+ async for raw in self._ws:
118
+ msg: dict[str, Any] = json.loads(raw)
119
+ if "id" in msg:
120
+ future = self._pending.pop(msg["id"], None)
121
+ if future and not future.done():
122
+ future.set_result(msg)
123
+ elif "method" in msg:
124
+ method: str = msg["method"]
125
+ params: dict[str, Any] = msg.get("params", {})
126
+ for cb in self._listeners.get(method, []):
127
+ result = cb(params)
128
+ if asyncio.iscoroutine(result):
129
+ asyncio.create_task(result)
130
+ except Exception as exc: # noqa: BLE001
131
+ logger.debug("CDP recv loop ended: %s", exc)
132
+ for future in self._pending.values():
133
+ if not future.done():
134
+ future.set_exception(CDPSessionError(f"Connection lost: {exc}"))
135
+ self._pending.clear()