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.
- harness_browser/__init__.py +18 -0
- harness_browser/actions/__init__.py +0 -0
- harness_browser/actions/capture.py +228 -0
- harness_browser/actions/interact.py +163 -0
- harness_browser/actions/js_eval.py +59 -0
- harness_browser/actions/navigate.py +76 -0
- harness_browser/cdp/__init__.py +0 -0
- harness_browser/cdp/client.py +135 -0
- harness_browser/cdp/launcher.py +337 -0
- harness_browser/dom/__init__.py +0 -0
- harness_browser/dom/builder.py +273 -0
- harness_browser/dom/refs.py +29 -0
- harness_browser/hooks.py +50 -0
- harness_browser/mcp_server.py +146 -0
- harness_browser/mode.py +75 -0
- harness_browser/models.py +42 -0
- harness_browser/profile.py +51 -0
- harness_browser/py.typed +0 -0
- harness_browser/session.py +516 -0
- harness_browser/settings.py +155 -0
- harness_browser/tool_interface.py +122 -0
- harness_browser-0.1.1.dist-info/METADATA +344 -0
- harness_browser-0.1.1.dist-info/RECORD +24 -0
- harness_browser-0.1.1.dist-info/WHEEL +4 -0
|
@@ -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()
|