bobby-browser 0.17.0__tar.gz
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.
- bobby_browser-0.17.0/PKG-INFO +56 -0
- bobby_browser-0.17.0/README.md +43 -0
- bobby_browser-0.17.0/bobby_browser/__init__.py +20 -0
- bobby_browser-0.17.0/bobby_browser/client.py +470 -0
- bobby_browser-0.17.0/bobby_browser/errors.py +101 -0
- bobby_browser-0.17.0/bobby_browser/py.typed +0 -0
- bobby_browser-0.17.0/bobby_browser.egg-info/PKG-INFO +56 -0
- bobby_browser-0.17.0/bobby_browser.egg-info/SOURCES.txt +12 -0
- bobby_browser-0.17.0/bobby_browser.egg-info/dependency_links.txt +1 -0
- bobby_browser-0.17.0/bobby_browser.egg-info/top_level.txt +1 -0
- bobby_browser-0.17.0/pyproject.toml +25 -0
- bobby_browser-0.17.0/setup.cfg +4 -0
- bobby_browser-0.17.0/tests/test_client.py +342 -0
- bobby_browser-0.17.0/tests/test_live_runtime.py +135 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bobby-browser
|
|
3
|
+
Version: 0.17.0
|
|
4
|
+
Summary: Typed client for the authenticated Bobby Browser runtime interface
|
|
5
|
+
Author: Sasan Sotoodehfar
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://cavi-ai.xyz/docs/bobby-browser
|
|
8
|
+
Project-URL: Repository, https://github.com/cavi-ai/bobby-browser
|
|
9
|
+
Project-URL: Issues, https://github.com/cavi-ai/bobby-browser/issues
|
|
10
|
+
Keywords: browser-automation,cdp,mcp,playwright,puppeteer,bobby-browser
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# bobby-browser (Python SDK)
|
|
15
|
+
|
|
16
|
+
Typed HTTP client for the authenticated Bobby Browser `/v1` runtime
|
|
17
|
+
interface. Stdlib only (`urllib`, `json`, `dataclasses`, `typing`) -- no
|
|
18
|
+
third-party HTTP client dependency. Python >= 3.10.
|
|
19
|
+
|
|
20
|
+
Mirrors `@cavi-ai/bobby-browser` (TypeScript,
|
|
21
|
+
`packages/typescript-sdk`) and `bobby-browser-client` (Rust,
|
|
22
|
+
`crates/bobby-browser-client`): same auth header contract, idempotency-key
|
|
23
|
+
passthrough, and `CommandOutcome` status discriminator.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install bobby-browser
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
From a bobby-browser checkout: `pip install -e packages/python-sdk`.
|
|
32
|
+
|
|
33
|
+
`bobby install --skill-hermes` copies `skill/hermes/SKILL.md` into
|
|
34
|
+
`$HERMES_HOME/skills/bobby-browser/` (else `~/.hermes/skills/`).
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import os
|
|
40
|
+
from bobby_browser import BrowserRuntimeClient
|
|
41
|
+
|
|
42
|
+
client = BrowserRuntimeClient("http://127.0.0.1:7777", os.environ["AUTOMATION_RUNTIME_TOKEN"])
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Full method catalog, headers, and error shape:
|
|
46
|
+
[docs/bobby-browser/source/pages/surfaces/python-sdk.md](../../docs/bobby-browser/source/pages/surfaces/python-sdk.md).
|
|
47
|
+
|
|
48
|
+
## Test
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python3 -m unittest discover -s tests
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`tests/test_live_runtime.py` additionally starts the real `bobby` runtime
|
|
55
|
+
from this worktree's release build and skips cleanly when
|
|
56
|
+
`BOBBY_CHROME_EXECUTABLE` is unset.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# bobby-browser (Python SDK)
|
|
2
|
+
|
|
3
|
+
Typed HTTP client for the authenticated Bobby Browser `/v1` runtime
|
|
4
|
+
interface. Stdlib only (`urllib`, `json`, `dataclasses`, `typing`) -- no
|
|
5
|
+
third-party HTTP client dependency. Python >= 3.10.
|
|
6
|
+
|
|
7
|
+
Mirrors `@cavi-ai/bobby-browser` (TypeScript,
|
|
8
|
+
`packages/typescript-sdk`) and `bobby-browser-client` (Rust,
|
|
9
|
+
`crates/bobby-browser-client`): same auth header contract, idempotency-key
|
|
10
|
+
passthrough, and `CommandOutcome` status discriminator.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install bobby-browser
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
From a bobby-browser checkout: `pip install -e packages/python-sdk`.
|
|
19
|
+
|
|
20
|
+
`bobby install --skill-hermes` copies `skill/hermes/SKILL.md` into
|
|
21
|
+
`$HERMES_HOME/skills/bobby-browser/` (else `~/.hermes/skills/`).
|
|
22
|
+
|
|
23
|
+
## Use
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import os
|
|
27
|
+
from bobby_browser import BrowserRuntimeClient
|
|
28
|
+
|
|
29
|
+
client = BrowserRuntimeClient("http://127.0.0.1:7777", os.environ["AUTOMATION_RUNTIME_TOKEN"])
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Full method catalog, headers, and error shape:
|
|
33
|
+
[docs/bobby-browser/source/pages/surfaces/python-sdk.md](../../docs/bobby-browser/source/pages/surfaces/python-sdk.md).
|
|
34
|
+
|
|
35
|
+
## Test
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python3 -m unittest discover -s tests
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`tests/test_live_runtime.py` additionally starts the real `bobby` runtime
|
|
42
|
+
from this worktree's release build and skips cleanly when
|
|
43
|
+
`BOBBY_CHROME_EXECUTABLE` is unset.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""``bobby-browser`` -- typed HTTP client for a Bobby Browser runtime
|
|
2
|
+
(``bobby serve``) speaking the authenticated ``/v1`` interface.
|
|
3
|
+
|
|
4
|
+
Pair with ``@cavi-ai/bobby-browser`` (TypeScript) or ``bobby-browser-client``
|
|
5
|
+
(Rust) for the same surface from other callers. Auth headers on every
|
|
6
|
+
request: ``Authorization: Bearer ...``, ``x-interface-version``,
|
|
7
|
+
``x-correlation-id``, and ``x-deadline``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .client import INTERFACE_VERSION, BrowserRuntimeClient, RequestOptions
|
|
11
|
+
from .errors import RuntimeClientError
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BrowserRuntimeClient",
|
|
15
|
+
"RequestOptions",
|
|
16
|
+
"RuntimeClientError",
|
|
17
|
+
"INTERFACE_VERSION",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
__version__ = "0.17.0"
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""HTTP client for the Bobby Browser ``/v1`` runtime interface.
|
|
2
|
+
|
|
3
|
+
Mirrors ``packages/typescript-sdk/src/client.ts``: every request sends
|
|
4
|
+
``Authorization``, ``x-interface-version``, ``x-correlation-id``, and
|
|
5
|
+
``x-deadline``; mutating calls accept an idempotency key; failures raise
|
|
6
|
+
:class:`~bobby_browser.errors.RuntimeClientError`. Stdlib only
|
|
7
|
+
(``urllib``, ``json``, ``dataclasses``, ``typing``) -- no third-party HTTP
|
|
8
|
+
client.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import datetime, timedelta, timezone
|
|
22
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
|
23
|
+
|
|
24
|
+
from .errors import RuntimeClientError
|
|
25
|
+
|
|
26
|
+
# Interface version negotiated via the `x-interface-version` request header.
|
|
27
|
+
# Keep aligned with packages/typescript-sdk/src/contracts.ts INTERFACE_VERSION.
|
|
28
|
+
INTERFACE_VERSION = "2026-08-19"
|
|
29
|
+
|
|
30
|
+
_DEFAULT_TIMEOUT_MS = 30_000
|
|
31
|
+
_JSON_CONTENT_TYPE = re.compile(r"^application/json(?:\s*;|$)", re.IGNORECASE)
|
|
32
|
+
|
|
33
|
+
# CommandOutcome.status -> expected HTTP status, mirroring client.ts's
|
|
34
|
+
# commandStatus().
|
|
35
|
+
_COMMAND_STATUS_HTTP: Dict[str, int] = {
|
|
36
|
+
"completed": 200,
|
|
37
|
+
"restarted": 200,
|
|
38
|
+
"retryableFailure": 503,
|
|
39
|
+
"needsReconciliation": 409,
|
|
40
|
+
"policyDenied": 403,
|
|
41
|
+
"resourceExhausted": 429,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# RecoveryDecision.status -> expected HTTP status, mirroring client.ts's
|
|
45
|
+
# `recover()` mapping.
|
|
46
|
+
_RECOVERY_NEEDS_RECONCILIATION_STATUS = "needsReconciliation"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class RequestOptions:
|
|
51
|
+
"""Per-call overrides. All fields are optional.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
timeout_ms: Relative timeout in milliseconds (default 30_000).
|
|
55
|
+
deadline: Absolute RFC3339 deadline string; combined with
|
|
56
|
+
``timeout_ms`` as the earlier of the two, same as the TS client.
|
|
57
|
+
correlation_id: Value for ``x-correlation-id`` (a UUID4 is
|
|
58
|
+
generated when omitted).
|
|
59
|
+
idempotency_key: Value for ``idempotency-key`` on mutating POSTs.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
timeout_ms: Optional[int] = None
|
|
63
|
+
deadline: Optional[str] = None
|
|
64
|
+
correlation_id: Optional[str] = None
|
|
65
|
+
idempotency_key: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _uuid4() -> str:
|
|
69
|
+
return str(uuid.uuid4())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _deadline_header(options: Optional[RequestOptions], default_timeout_ms: int) -> str:
|
|
73
|
+
if options is not None and options.deadline:
|
|
74
|
+
return options.deadline
|
|
75
|
+
timeout_ms = default_timeout_ms
|
|
76
|
+
if options is not None and options.timeout_ms is not None:
|
|
77
|
+
timeout_ms = options.timeout_ms
|
|
78
|
+
deadline = datetime.now(timezone.utc) + timedelta(milliseconds=timeout_ms)
|
|
79
|
+
return deadline.strftime("%Y-%m-%dT%H:%M:%S.") + f"{deadline.microsecond // 1000:03d}Z"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _header_get(headers: Mapping[str, str], name: str) -> Optional[str]:
|
|
83
|
+
lowered = name.lower()
|
|
84
|
+
for key, value in headers.items():
|
|
85
|
+
if key.lower() == lowered:
|
|
86
|
+
return value
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _content_type(headers: Mapping[str, str]) -> str:
|
|
91
|
+
raw = _header_get(headers, "content-type") or ""
|
|
92
|
+
return raw.split(";", 1)[0].strip().lower()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _media_type_essence(value: str) -> Optional[str]:
|
|
96
|
+
essence = value.split(";", 1)[0].strip().lower()
|
|
97
|
+
if essence and re.match(r"^[!#$%&'*+.^_`|~0-9a-z-]+/[!#$%&'*+.^_`|~0-9a-z-]+$", essence):
|
|
98
|
+
return essence
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class BrowserRuntimeClient:
|
|
103
|
+
"""Authenticated HTTP client for a Bobby Browser runtime (``bobby serve``).
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
base_url: Runtime origin. A trailing slash and a trailing ``/v1``
|
|
107
|
+
are stripped, so either ``http://127.0.0.1:7777`` or
|
|
108
|
+
``http://127.0.0.1:7777/v1`` works.
|
|
109
|
+
bearer_token: Bearer credential for ``Authorization``. Never sent
|
|
110
|
+
anywhere but that header, never logged, never put in a URL.
|
|
111
|
+
timeout_ms: Default relative timeout for calls that do not pass
|
|
112
|
+
``options`` (default 30_000).
|
|
113
|
+
opener: Override ``urllib.request`` opener (tests only).
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
base_url: str,
|
|
119
|
+
bearer_token: str,
|
|
120
|
+
*,
|
|
121
|
+
timeout_ms: int = _DEFAULT_TIMEOUT_MS,
|
|
122
|
+
opener: Optional[urllib.request.OpenerDirector] = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
if not base_url or not bearer_token:
|
|
125
|
+
raise ValueError("base_url and bearer_token are required")
|
|
126
|
+
stripped = base_url.rstrip("/")
|
|
127
|
+
if stripped.endswith("/v1"):
|
|
128
|
+
stripped = stripped[: -len("/v1")]
|
|
129
|
+
self._base_url = stripped
|
|
130
|
+
self._bearer_token = bearer_token
|
|
131
|
+
self._timeout_ms = timeout_ms
|
|
132
|
+
self._opener = opener or urllib.request.build_opener()
|
|
133
|
+
|
|
134
|
+
def __repr__(self) -> str: # never print the bearer token
|
|
135
|
+
return "BrowserRuntimeClient(bearer_token=[redacted])"
|
|
136
|
+
|
|
137
|
+
# ---- sessions ---------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def runtime_info(self, options: Optional[RequestOptions] = None) -> Dict[str, Any]:
|
|
140
|
+
"""``GET /v1/runtime`` -- version, capabilities, and load counters."""
|
|
141
|
+
return self._json("GET", "/v1/runtime", None, options, expected_status=200)
|
|
142
|
+
|
|
143
|
+
def create_session(
|
|
144
|
+
self, input: Mapping[str, Any], options: Optional[RequestOptions] = None
|
|
145
|
+
) -> Dict[str, Any]:
|
|
146
|
+
"""``POST /v1/sessions`` -- create a browser session."""
|
|
147
|
+
return self._json("POST", "/v1/sessions", input, options, expected_status=200)
|
|
148
|
+
|
|
149
|
+
def read_session(
|
|
150
|
+
self, session_id: Optional[str] = None, options: Optional[RequestOptions] = None
|
|
151
|
+
) -> Any:
|
|
152
|
+
"""Read session state.
|
|
153
|
+
|
|
154
|
+
There is no ``GET /v1/sessions/{id}`` on the wire, only
|
|
155
|
+
``GET /v1/sessions`` (full array, no pagination). With
|
|
156
|
+
``session_id`` omitted this returns that full list; with it set,
|
|
157
|
+
this filters client-side and returns the one matching
|
|
158
|
+
:class:`SessionState`, raising :class:`RuntimeClientError` with
|
|
159
|
+
``kind="protocol"`` if no session with that id is active.
|
|
160
|
+
"""
|
|
161
|
+
sessions = self._json("GET", "/v1/sessions", None, options, expected_status=200)
|
|
162
|
+
if not isinstance(sessions, list):
|
|
163
|
+
raise self._protocol("sessions response has an unexpected shape")
|
|
164
|
+
if session_id is None:
|
|
165
|
+
return sessions
|
|
166
|
+
for session in sessions:
|
|
167
|
+
if isinstance(session, dict) and session.get("id") == session_id:
|
|
168
|
+
return session
|
|
169
|
+
raise self._protocol(f"no active session with id {session_id!r}", 200)
|
|
170
|
+
|
|
171
|
+
def delete_session(self, session_id: str, options: Optional[RequestOptions] = None) -> None:
|
|
172
|
+
"""``DELETE /v1/sessions/{id}`` -- tear down a session (204 on success)."""
|
|
173
|
+
self._empty("DELETE", f"/v1/sessions/{urllib.parse.quote(session_id)}", options)
|
|
174
|
+
|
|
175
|
+
# ---- pages --------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def open_page(
|
|
178
|
+
self, input: Mapping[str, Any], options: Optional[RequestOptions] = None
|
|
179
|
+
) -> Dict[str, Any]:
|
|
180
|
+
"""``POST /v1/pages`` -- open a page in a session."""
|
|
181
|
+
return self._json("POST", "/v1/pages", input, options, expected_status=200)
|
|
182
|
+
|
|
183
|
+
def read_page(
|
|
184
|
+
self,
|
|
185
|
+
session_id: str,
|
|
186
|
+
page_id: str,
|
|
187
|
+
*,
|
|
188
|
+
max_controls: Optional[int] = None,
|
|
189
|
+
options: Optional[RequestOptions] = None,
|
|
190
|
+
) -> Dict[str, Any]:
|
|
191
|
+
"""``GET /v1/sessions/{session}/pages/{page}/forms`` -- read-only
|
|
192
|
+
``FormSnapshot`` (the PageRead HTTP surface; same contract as MCP
|
|
193
|
+
``form_snapshot``). Page-derived controls carry ``pageDerived: true``.
|
|
194
|
+
``max_controls`` is optional, 1 through 512.
|
|
195
|
+
"""
|
|
196
|
+
if max_controls is not None and not (1 <= max_controls <= 512):
|
|
197
|
+
raise self._protocol("max_controls must be between 1 and 512")
|
|
198
|
+
query = f"?maxControls={max_controls}" if max_controls is not None else ""
|
|
199
|
+
path = (
|
|
200
|
+
f"/v1/sessions/{urllib.parse.quote(session_id)}"
|
|
201
|
+
f"/pages/{urllib.parse.quote(page_id)}/forms{query}"
|
|
202
|
+
)
|
|
203
|
+
return self._json("GET", path, None, options, expected_status=200)
|
|
204
|
+
|
|
205
|
+
# ---- commands -------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def submit_command(
|
|
208
|
+
self, envelope: Mapping[str, Any], options: Optional[RequestOptions] = None
|
|
209
|
+
) -> Dict[str, Any]:
|
|
210
|
+
"""``POST /v1/commands`` -- submit a raw ``CommandEnvelope``.
|
|
211
|
+
|
|
212
|
+
Returns the ``CommandOutcome`` body unmodified (its ``status``
|
|
213
|
+
discriminator field is preserved exactly as the server sent it --
|
|
214
|
+
``completed``, ``retryableFailure``, ``needsReconciliation``,
|
|
215
|
+
``policyDenied``, ``resourceExhausted``, ``restarted``, or
|
|
216
|
+
``failed``) after checking the HTTP status matches the documented
|
|
217
|
+
mapping for that status.
|
|
218
|
+
Page-derived inspection, accessibility, form, and extraction evidence
|
|
219
|
+
retains its ``pageDerived: true`` marker in the returned dictionary.
|
|
220
|
+
"""
|
|
221
|
+
status, payload = self._request("POST", "/v1/commands", envelope, options)
|
|
222
|
+
if not isinstance(payload, dict) or "status" not in payload:
|
|
223
|
+
raise self._response_error(status, payload)
|
|
224
|
+
outcome_status = payload["status"]
|
|
225
|
+
expected = _command_outcome_http_status(payload)
|
|
226
|
+
if expected is None:
|
|
227
|
+
raise self._protocol(f"unknown command outcome status: {outcome_status!r}", status)
|
|
228
|
+
if expected != status:
|
|
229
|
+
raise self._protocol(
|
|
230
|
+
"command outcome status does not match HTTP mapping", status
|
|
231
|
+
)
|
|
232
|
+
return payload
|
|
233
|
+
|
|
234
|
+
# ---- checkpoints / recovery ------------------------------------------
|
|
235
|
+
|
|
236
|
+
def create_checkpoint(
|
|
237
|
+
self, input: Mapping[str, Any], options: Optional[RequestOptions] = None
|
|
238
|
+
) -> Dict[str, Any]:
|
|
239
|
+
"""``POST /v1/checkpoints`` -- persist a workflow checkpoint."""
|
|
240
|
+
return self._json("POST", "/v1/checkpoints", input, options, expected_status=200)
|
|
241
|
+
|
|
242
|
+
def recovery_status(
|
|
243
|
+
self, workflow_id: str, options: Optional[RequestOptions] = None
|
|
244
|
+
) -> Dict[str, Any]:
|
|
245
|
+
"""``GET /v1/recovery/{workflowId}`` -- current recovery status."""
|
|
246
|
+
path = f"/v1/recovery/{urllib.parse.quote(workflow_id)}"
|
|
247
|
+
return self._json("GET", path, None, options, expected_status=200)
|
|
248
|
+
|
|
249
|
+
def recover_workflow(
|
|
250
|
+
self, workflow_id: str, options: Optional[RequestOptions] = None
|
|
251
|
+
) -> Dict[str, Any]:
|
|
252
|
+
"""``POST /v1/recovery/{workflowId}`` -- resume, reconcile, or
|
|
253
|
+
restart a workflow. ``needsReconciliation`` maps to HTTP 409;
|
|
254
|
+
every other decision maps to 200.
|
|
255
|
+
"""
|
|
256
|
+
path = f"/v1/recovery/{urllib.parse.quote(workflow_id)}"
|
|
257
|
+
status, payload = self._request("POST", path, None, options)
|
|
258
|
+
if not isinstance(payload, dict) or "status" not in payload:
|
|
259
|
+
raise self._response_error(status, payload)
|
|
260
|
+
expected = 409 if payload["status"] == _RECOVERY_NEEDS_RECONCILIATION_STATUS else 200
|
|
261
|
+
if expected != status:
|
|
262
|
+
raise self._protocol(
|
|
263
|
+
"recovery decision status does not match HTTP mapping", status
|
|
264
|
+
)
|
|
265
|
+
return payload
|
|
266
|
+
|
|
267
|
+
# ---- context --------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def context_ask(
|
|
270
|
+
self,
|
|
271
|
+
session_id: str,
|
|
272
|
+
page_id: str,
|
|
273
|
+
description: str,
|
|
274
|
+
options: Optional[RequestOptions] = None,
|
|
275
|
+
) -> Dict[str, Any]:
|
|
276
|
+
"""``GET /v1/context/ask`` -- remembered target for a description.
|
|
277
|
+
|
|
278
|
+
The result carries ``pageDerived: true`` on either a hit or miss.
|
|
279
|
+
"""
|
|
280
|
+
encoded = len(description.encode("utf-8"))
|
|
281
|
+
if not (1 <= encoded <= 256):
|
|
282
|
+
raise self._protocol("description must contain between 1 and 256 bytes")
|
|
283
|
+
query = urllib.parse.urlencode(
|
|
284
|
+
{"sessionId": session_id, "pageId": page_id, "description": description}
|
|
285
|
+
)
|
|
286
|
+
return self._json("GET", f"/v1/context/ask?{query}", None, options, expected_status=200)
|
|
287
|
+
|
|
288
|
+
def context_site(
|
|
289
|
+
self, site_key: str, options: Optional[RequestOptions] = None
|
|
290
|
+
) -> Dict[str, Any]:
|
|
291
|
+
"""``GET /v1/context/site/{key}`` -- durable per-site context view."""
|
|
292
|
+
if not site_key:
|
|
293
|
+
raise self._protocol("site key must not be empty")
|
|
294
|
+
path = f"/v1/context/site/{urllib.parse.quote(site_key)}"
|
|
295
|
+
return self._json("GET", path, None, options, expected_status=200)
|
|
296
|
+
|
|
297
|
+
# ---- artifacts --------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
def read_artifact(
|
|
300
|
+
self, reference: Mapping[str, Any], options: Optional[RequestOptions] = None
|
|
301
|
+
) -> bytes:
|
|
302
|
+
"""``GET /v1/artifacts/{artifactId}`` -- verified artifact bytes.
|
|
303
|
+
|
|
304
|
+
``reference`` is an ``ArtifactReference``: ``artifactId``,
|
|
305
|
+
``sha256``, ``bytes``, and ``mediaType``. Checks ``Content-Type``
|
|
306
|
+
and ``Content-Length`` against the reference, then verifies
|
|
307
|
+
SHA-256 before returning anything -- no bytes are handed back on a
|
|
308
|
+
mismatch.
|
|
309
|
+
"""
|
|
310
|
+
artifact_id = reference.get("artifactId")
|
|
311
|
+
if not artifact_id or not isinstance(artifact_id, str):
|
|
312
|
+
raise self._protocol("artifact reference is missing artifactId")
|
|
313
|
+
path = f"/v1/artifacts/{urllib.parse.quote(artifact_id)}"
|
|
314
|
+
status, content_type, headers, raw = self._request_raw("GET", path, None, options)
|
|
315
|
+
if status != 200:
|
|
316
|
+
raise self._response_error(status, self._decode_json_or_none(raw, content_type))
|
|
317
|
+
expected_media_type = _media_type_essence(str(reference.get("mediaType", "")))
|
|
318
|
+
if expected_media_type is None or content_type != expected_media_type:
|
|
319
|
+
raise self._protocol("artifact media type does not match its reference", status)
|
|
320
|
+
expected_bytes = reference.get("bytes")
|
|
321
|
+
content_length = _header_get(headers, "content-length")
|
|
322
|
+
if (
|
|
323
|
+
content_length is None
|
|
324
|
+
or not content_length.isdigit()
|
|
325
|
+
or int(content_length) != expected_bytes
|
|
326
|
+
or len(raw) != expected_bytes
|
|
327
|
+
):
|
|
328
|
+
raise self._protocol("artifact content length does not match its reference", status)
|
|
329
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
330
|
+
expected_sha256 = str(reference.get("sha256", "")).lower()
|
|
331
|
+
if digest != expected_sha256:
|
|
332
|
+
raise self._protocol("artifact digest does not match its reference", status)
|
|
333
|
+
return raw
|
|
334
|
+
|
|
335
|
+
# ---- jobs --------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
def submit_job(
|
|
338
|
+
self, input: Mapping[str, Any], options: Optional[RequestOptions] = None
|
|
339
|
+
) -> Dict[str, Any]:
|
|
340
|
+
"""``POST /v1/jobs`` -- submit a bounded runtime job."""
|
|
341
|
+
return self._json("POST", "/v1/jobs", input, options, expected_status=201)
|
|
342
|
+
|
|
343
|
+
def job_status(self, job_id: str, options: Optional[RequestOptions] = None) -> Dict[str, Any]:
|
|
344
|
+
"""``GET /v1/jobs/{jobId}`` -- read the authenticated principal's job."""
|
|
345
|
+
path = f"/v1/jobs/{urllib.parse.quote(job_id)}"
|
|
346
|
+
return self._json("GET", path, None, options, expected_status=200)
|
|
347
|
+
|
|
348
|
+
def cancel_job(self, job_id: str, options: Optional[RequestOptions] = None) -> None:
|
|
349
|
+
"""``DELETE /v1/jobs/{jobId}`` -- cancel the authenticated principal's job."""
|
|
350
|
+
self._empty("DELETE", f"/v1/jobs/{urllib.parse.quote(job_id)}", options)
|
|
351
|
+
|
|
352
|
+
# ---- transport --------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
def _headers(self, options: Optional[RequestOptions], has_body: bool) -> Dict[str, str]:
|
|
355
|
+
correlation_id = (options.correlation_id if options else None) or _uuid4()
|
|
356
|
+
headers = {
|
|
357
|
+
"Authorization": f"Bearer {self._bearer_token}",
|
|
358
|
+
"x-interface-version": INTERFACE_VERSION,
|
|
359
|
+
"x-correlation-id": correlation_id,
|
|
360
|
+
"x-deadline": _deadline_header(options, self._timeout_ms),
|
|
361
|
+
}
|
|
362
|
+
if options is not None and options.idempotency_key:
|
|
363
|
+
headers["idempotency-key"] = options.idempotency_key
|
|
364
|
+
if has_body:
|
|
365
|
+
headers["content-type"] = "application/json"
|
|
366
|
+
return headers
|
|
367
|
+
|
|
368
|
+
def _request_raw(
|
|
369
|
+
self,
|
|
370
|
+
method: str,
|
|
371
|
+
path: str,
|
|
372
|
+
body: Optional[Mapping[str, Any]],
|
|
373
|
+
options: Optional[RequestOptions],
|
|
374
|
+
) -> tuple:
|
|
375
|
+
"""Returns (status, content_type, headers, raw_bytes)."""
|
|
376
|
+
url = f"{self._base_url}{path}"
|
|
377
|
+
headers = self._headers(options, body is not None)
|
|
378
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
379
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
380
|
+
timeout_ms = self._timeout_ms
|
|
381
|
+
if options is not None and options.timeout_ms is not None:
|
|
382
|
+
timeout_ms = options.timeout_ms
|
|
383
|
+
try:
|
|
384
|
+
response = self._opener.open(request, timeout=timeout_ms / 1000)
|
|
385
|
+
try:
|
|
386
|
+
status = response.status
|
|
387
|
+
response_headers = dict(response.headers.items())
|
|
388
|
+
raw = response.read()
|
|
389
|
+
finally:
|
|
390
|
+
response.close()
|
|
391
|
+
except urllib.error.HTTPError as error:
|
|
392
|
+
status = error.code
|
|
393
|
+
response_headers = dict(error.headers.items()) if error.headers else {}
|
|
394
|
+
raw = error.read()
|
|
395
|
+
error.close()
|
|
396
|
+
except TimeoutError as error:
|
|
397
|
+
raise RuntimeClientError("deadline", message="Request deadline exceeded") from error
|
|
398
|
+
except urllib.error.URLError as error:
|
|
399
|
+
raise RuntimeClientError(
|
|
400
|
+
"transport", message=f"Runtime transport request failed: {error.reason}"
|
|
401
|
+
) from error
|
|
402
|
+
content_type = _content_type(response_headers)
|
|
403
|
+
return status, content_type, response_headers, raw
|
|
404
|
+
|
|
405
|
+
def _decode_json_or_none(self, raw: bytes, content_type: str) -> Any:
|
|
406
|
+
if not _JSON_CONTENT_TYPE.match(content_type):
|
|
407
|
+
return None
|
|
408
|
+
try:
|
|
409
|
+
return json.loads(raw.decode("utf-8"))
|
|
410
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
def _request(
|
|
414
|
+
self,
|
|
415
|
+
method: str,
|
|
416
|
+
path: str,
|
|
417
|
+
body: Optional[Mapping[str, Any]],
|
|
418
|
+
options: Optional[RequestOptions],
|
|
419
|
+
) -> tuple:
|
|
420
|
+
"""Returns (status, parsed_json_payload)."""
|
|
421
|
+
status, content_type, _headers, raw = self._request_raw(method, path, body, options)
|
|
422
|
+
if not _JSON_CONTENT_TYPE.match(content_type):
|
|
423
|
+
raise self._protocol("response content type must be application/json", status)
|
|
424
|
+
try:
|
|
425
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
426
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
427
|
+
raise self._protocol("response body is not valid JSON", status) from error
|
|
428
|
+
return status, payload
|
|
429
|
+
|
|
430
|
+
def _json(
|
|
431
|
+
self,
|
|
432
|
+
method: str,
|
|
433
|
+
path: str,
|
|
434
|
+
body: Optional[Mapping[str, Any]],
|
|
435
|
+
options: Optional[RequestOptions],
|
|
436
|
+
*,
|
|
437
|
+
expected_status: int,
|
|
438
|
+
) -> Any:
|
|
439
|
+
status, payload = self._request(method, path, body, options)
|
|
440
|
+
if status != expected_status:
|
|
441
|
+
raise self._response_error(status, payload)
|
|
442
|
+
return payload
|
|
443
|
+
|
|
444
|
+
def _empty(self, method: str, path: str, options: Optional[RequestOptions]) -> None:
|
|
445
|
+
status, content_type, _headers, raw = self._request_raw(method, path, None, options)
|
|
446
|
+
if status == 204:
|
|
447
|
+
return
|
|
448
|
+
payload = self._decode_json_or_none(raw, content_type)
|
|
449
|
+
raise self._response_error(status, payload)
|
|
450
|
+
|
|
451
|
+
def _response_error(self, status: int, payload: Any) -> RuntimeClientError:
|
|
452
|
+
if isinstance(payload, dict) and set(payload.keys()) == {"error"} and isinstance(
|
|
453
|
+
payload["error"], dict
|
|
454
|
+
):
|
|
455
|
+
return RuntimeClientError.from_interface_error("http", status, payload["error"])
|
|
456
|
+
return self._protocol("response has an unexpected status or shape", status)
|
|
457
|
+
|
|
458
|
+
def _protocol(self, message: str, status: Optional[int] = None) -> RuntimeClientError:
|
|
459
|
+
return RuntimeClientError("protocol", status=status, message=message)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _command_outcome_http_status(outcome: Mapping[str, Any]) -> Optional[int]:
|
|
463
|
+
"""Mirrors client.ts's commandStatus(): CommandOutcome.status -> HTTP status."""
|
|
464
|
+
status = outcome.get("status")
|
|
465
|
+
if status in _COMMAND_STATUS_HTTP:
|
|
466
|
+
return _COMMAND_STATUS_HTTP[status]
|
|
467
|
+
if status == "failed":
|
|
468
|
+
error = outcome.get("error") or {}
|
|
469
|
+
return 422 if error.get("code") == "invalidRequest" else 500
|
|
470
|
+
return None
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Client-side error type for the Bobby Browser Python SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors ``RuntimeClientError`` from the TypeScript SDK
|
|
4
|
+
(``packages/typescript-sdk/src/errors.ts``): a single exception class
|
|
5
|
+
classified by ``kind``, carrying the fields a caller needs to decide whether
|
|
6
|
+
to retry, without ever retaining the bearer token.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Mapping, Optional
|
|
12
|
+
|
|
13
|
+
# Classification of RuntimeClientError, matching the TypeScript
|
|
14
|
+
# RuntimeClientErrorKind union exactly.
|
|
15
|
+
RUNTIME_CLIENT_ERROR_KINDS = ("transport", "protocol", "http", "aborted", "deadline")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RuntimeClientError(Exception):
|
|
19
|
+
"""Raised for every failure the client cannot recover from itself.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
kind: One of ``"transport"``, ``"protocol"``, ``"http"``,
|
|
23
|
+
``"aborted"``, ``"deadline"``.
|
|
24
|
+
status: HTTP status code, when the failure followed a response.
|
|
25
|
+
code: Wire ``InterfaceErrorCode``, when the server returned one.
|
|
26
|
+
correlation_id: The request's ``x-correlation-id``, when known.
|
|
27
|
+
command_id: The command id an interface error referenced, if any.
|
|
28
|
+
retryable: Whether the server marked the failure retryable.
|
|
29
|
+
retry_after_ms: Minimum backoff before retrying, when the server
|
|
30
|
+
supplied one (notably on HTTP 429).
|
|
31
|
+
reconciliation_required: Whether the caller must reconcile a
|
|
32
|
+
workflow's checkpoint before retrying.
|
|
33
|
+
required_capability: The capability the caller was missing, if any.
|
|
34
|
+
event_gap: The ``EventGap`` payload, for a 409 on ``events()``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
kind: str,
|
|
40
|
+
*,
|
|
41
|
+
status: Optional[int] = None,
|
|
42
|
+
code: Optional[str] = None,
|
|
43
|
+
message: Optional[str] = None,
|
|
44
|
+
correlation_id: Optional[str] = None,
|
|
45
|
+
command_id: Optional[Any] = None,
|
|
46
|
+
retryable: Optional[bool] = None,
|
|
47
|
+
retry_after_ms: Optional[int] = None,
|
|
48
|
+
reconciliation_required: Optional[bool] = None,
|
|
49
|
+
required_capability: Optional[Any] = None,
|
|
50
|
+
event_gap: Optional[Mapping[str, Any]] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
if kind not in RUNTIME_CLIENT_ERROR_KINDS:
|
|
53
|
+
raise ValueError(f"unknown RuntimeClientError kind: {kind!r}")
|
|
54
|
+
resolved_message = message or f"Runtime client {kind} failure"
|
|
55
|
+
super().__init__(resolved_message)
|
|
56
|
+
self.kind = kind
|
|
57
|
+
self.status = status
|
|
58
|
+
self.code = code
|
|
59
|
+
self.correlation_id = correlation_id
|
|
60
|
+
self.command_id = command_id
|
|
61
|
+
self.retryable = retryable
|
|
62
|
+
self.retry_after_ms = retry_after_ms
|
|
63
|
+
self.reconciliation_required = reconciliation_required
|
|
64
|
+
self.required_capability = required_capability
|
|
65
|
+
self.event_gap = dict(event_gap) if event_gap is not None else None
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_interface_error(
|
|
69
|
+
cls, kind: str, status: int, error: Mapping[str, Any]
|
|
70
|
+
) -> "RuntimeClientError":
|
|
71
|
+
"""Build from a wire ``InterfaceError`` (``{"error": {...}}`` body)."""
|
|
72
|
+
return cls(
|
|
73
|
+
kind,
|
|
74
|
+
status=status,
|
|
75
|
+
code=error.get("code"),
|
|
76
|
+
message=f"Runtime request failed: {status} {error.get('code')}",
|
|
77
|
+
correlation_id=error.get("correlationId"),
|
|
78
|
+
command_id=error.get("commandId"),
|
|
79
|
+
retryable=error.get("retryable"),
|
|
80
|
+
retry_after_ms=error.get("retryAfterMs"),
|
|
81
|
+
reconciliation_required=error.get("reconciliationRequired"),
|
|
82
|
+
required_capability=error.get("requiredCapability"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
86
|
+
return f"RuntimeClientError(kind={self.kind!r}, status={self.status!r}, code={self.code!r})"
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict:
|
|
89
|
+
"""JSON-safe projection for logging."""
|
|
90
|
+
return {
|
|
91
|
+
"kind": self.kind,
|
|
92
|
+
"status": self.status,
|
|
93
|
+
"code": self.code,
|
|
94
|
+
"correlationId": self.correlation_id,
|
|
95
|
+
"commandId": self.command_id,
|
|
96
|
+
"retryable": self.retryable,
|
|
97
|
+
"retryAfterMs": self.retry_after_ms,
|
|
98
|
+
"reconciliationRequired": self.reconciliation_required,
|
|
99
|
+
"requiredCapability": self.required_capability,
|
|
100
|
+
"eventGap": self.event_gap,
|
|
101
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bobby-browser
|
|
3
|
+
Version: 0.17.0
|
|
4
|
+
Summary: Typed client for the authenticated Bobby Browser runtime interface
|
|
5
|
+
Author: Sasan Sotoodehfar
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://cavi-ai.xyz/docs/bobby-browser
|
|
8
|
+
Project-URL: Repository, https://github.com/cavi-ai/bobby-browser
|
|
9
|
+
Project-URL: Issues, https://github.com/cavi-ai/bobby-browser/issues
|
|
10
|
+
Keywords: browser-automation,cdp,mcp,playwright,puppeteer,bobby-browser
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# bobby-browser (Python SDK)
|
|
15
|
+
|
|
16
|
+
Typed HTTP client for the authenticated Bobby Browser `/v1` runtime
|
|
17
|
+
interface. Stdlib only (`urllib`, `json`, `dataclasses`, `typing`) -- no
|
|
18
|
+
third-party HTTP client dependency. Python >= 3.10.
|
|
19
|
+
|
|
20
|
+
Mirrors `@cavi-ai/bobby-browser` (TypeScript,
|
|
21
|
+
`packages/typescript-sdk`) and `bobby-browser-client` (Rust,
|
|
22
|
+
`crates/bobby-browser-client`): same auth header contract, idempotency-key
|
|
23
|
+
passthrough, and `CommandOutcome` status discriminator.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install bobby-browser
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
From a bobby-browser checkout: `pip install -e packages/python-sdk`.
|
|
32
|
+
|
|
33
|
+
`bobby install --skill-hermes` copies `skill/hermes/SKILL.md` into
|
|
34
|
+
`$HERMES_HOME/skills/bobby-browser/` (else `~/.hermes/skills/`).
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import os
|
|
40
|
+
from bobby_browser import BrowserRuntimeClient
|
|
41
|
+
|
|
42
|
+
client = BrowserRuntimeClient("http://127.0.0.1:7777", os.environ["AUTOMATION_RUNTIME_TOKEN"])
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Full method catalog, headers, and error shape:
|
|
46
|
+
[docs/bobby-browser/source/pages/surfaces/python-sdk.md](../../docs/bobby-browser/source/pages/surfaces/python-sdk.md).
|
|
47
|
+
|
|
48
|
+
## Test
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python3 -m unittest discover -s tests
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`tests/test_live_runtime.py` additionally starts the real `bobby` runtime
|
|
55
|
+
from this worktree's release build and skips cleanly when
|
|
56
|
+
`BOBBY_CHROME_EXECUTABLE` is unset.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
bobby_browser/__init__.py
|
|
4
|
+
bobby_browser/client.py
|
|
5
|
+
bobby_browser/errors.py
|
|
6
|
+
bobby_browser/py.typed
|
|
7
|
+
bobby_browser.egg-info/PKG-INFO
|
|
8
|
+
bobby_browser.egg-info/SOURCES.txt
|
|
9
|
+
bobby_browser.egg-info/dependency_links.txt
|
|
10
|
+
bobby_browser.egg-info/top_level.txt
|
|
11
|
+
tests/test_client.py
|
|
12
|
+
tests/test_live_runtime.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bobby_browser
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bobby-browser"
|
|
7
|
+
version = "0.17.0"
|
|
8
|
+
description = "Typed client for the authenticated Bobby Browser runtime interface"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Sasan Sotoodehfar" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["browser-automation", "cdp", "mcp", "playwright", "puppeteer", "bobby-browser"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://cavi-ai.xyz/docs/bobby-browser"
|
|
18
|
+
Repository = "https://github.com/cavi-ai/bobby-browser"
|
|
19
|
+
Issues = "https://github.com/cavi-ai/bobby-browser/issues"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
include = ["bobby_browser*"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.package-data]
|
|
25
|
+
bobby_browser = ["py.typed"]
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Unit tests for bobby_browser.client against a local fake HTTP server.
|
|
2
|
+
|
|
3
|
+
No real bobby-browser runtime involved -- see test_live_runtime.py for the
|
|
4
|
+
one test that starts the real thing. These cover: the auth header contract,
|
|
5
|
+
idempotency-key passthrough, the 429 retryAfterMs surface, the 409
|
|
6
|
+
needsReconciliation surface on submit_command, and that the CommandOutcome
|
|
7
|
+
status discriminator survives the round trip unmodified.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import unittest
|
|
17
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
# `python3 -m unittest discover -s packages/python-sdk/tests` (the CI /
|
|
22
|
+
# check-version-agreement.py-adjacent gate) runs straight from a checkout
|
|
23
|
+
# with no `pip install` step first. `packages/python-sdk` (this file's
|
|
24
|
+
# grandparent) is the source package root; put it on sys.path so
|
|
25
|
+
# `bobby_browser` resolves to the checkout's own source.
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
27
|
+
|
|
28
|
+
from bobby_browser import BrowserRuntimeClient, RequestOptions, RuntimeClientError # noqa: E402
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
32
|
+
def log_message(self, format, *args): # noqa: A002 - stdlib signature
|
|
33
|
+
pass # keep test output quiet
|
|
34
|
+
|
|
35
|
+
def _dispatch(self) -> None:
|
|
36
|
+
self.server.route_handler(self) # type: ignore[attr-defined]
|
|
37
|
+
|
|
38
|
+
def do_GET(self) -> None: # noqa: N802 - stdlib naming
|
|
39
|
+
self._dispatch()
|
|
40
|
+
|
|
41
|
+
def do_POST(self) -> None: # noqa: N802
|
|
42
|
+
self._dispatch()
|
|
43
|
+
|
|
44
|
+
def do_DELETE(self) -> None: # noqa: N802
|
|
45
|
+
self._dispatch()
|
|
46
|
+
|
|
47
|
+
def read_body(self) -> bytes:
|
|
48
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
49
|
+
return self.rfile.read(length) if length else b""
|
|
50
|
+
|
|
51
|
+
def reply_json(self, status: int, payload) -> None:
|
|
52
|
+
body = json.dumps(payload).encode("utf-8")
|
|
53
|
+
self.send_response(status)
|
|
54
|
+
self.send_header("Content-Type", "application/json")
|
|
55
|
+
self.send_header("Content-Length", str(len(body)))
|
|
56
|
+
self.end_headers()
|
|
57
|
+
self.wfile.write(body)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _FakeServer:
|
|
61
|
+
def __init__(self, handler: Callable[[_Handler], None]) -> None:
|
|
62
|
+
self.httpd = HTTPServer(("127.0.0.1", 0), _Handler)
|
|
63
|
+
self.httpd.route_handler = handler # type: ignore[attr-defined]
|
|
64
|
+
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
|
65
|
+
self.thread.start()
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def base_url(self) -> str:
|
|
69
|
+
return f"http://127.0.0.1:{self.httpd.server_port}"
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
self.httpd.shutdown()
|
|
73
|
+
self.httpd.server_close()
|
|
74
|
+
self.thread.join(timeout=5)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ClientUnitTests(unittest.TestCase):
|
|
78
|
+
def _serve(self, handler: Callable[[_Handler], None]) -> _FakeServer:
|
|
79
|
+
server = _FakeServer(handler)
|
|
80
|
+
self.addCleanup(server.close)
|
|
81
|
+
return server
|
|
82
|
+
|
|
83
|
+
# ---- auth header contract ------------------------------------------
|
|
84
|
+
|
|
85
|
+
def test_every_request_carries_the_documented_headers(self) -> None:
|
|
86
|
+
seen = {}
|
|
87
|
+
|
|
88
|
+
def handler(req: _Handler) -> None:
|
|
89
|
+
req.read_body()
|
|
90
|
+
seen["authorization"] = req.headers.get("Authorization")
|
|
91
|
+
seen["interface_version"] = req.headers.get("x-interface-version")
|
|
92
|
+
seen["correlation_id"] = req.headers.get("x-correlation-id")
|
|
93
|
+
seen["deadline"] = req.headers.get("x-deadline")
|
|
94
|
+
req.reply_json(200, {"version": "1.2.3", "capabilities": [], "active_sessions": 0, "queued_jobs": 0, "uptime_ms": 1})
|
|
95
|
+
|
|
96
|
+
server = self._serve(handler)
|
|
97
|
+
client = BrowserRuntimeClient(server.base_url, "top-secret-bearer")
|
|
98
|
+
info = client.runtime_info()
|
|
99
|
+
|
|
100
|
+
self.assertEqual(info["version"], "1.2.3")
|
|
101
|
+
self.assertEqual(seen["authorization"], "Bearer top-secret-bearer")
|
|
102
|
+
self.assertEqual(seen["interface_version"], "2026-08-19")
|
|
103
|
+
self.assertTrue(seen["correlation_id"])
|
|
104
|
+
self.assertTrue(seen["deadline"] and seen["deadline"].endswith("Z"))
|
|
105
|
+
# Never a credential in the URL.
|
|
106
|
+
self.assertNotIn("top-secret-bearer", server.base_url)
|
|
107
|
+
|
|
108
|
+
def test_bearer_token_never_appears_in_repr(self) -> None:
|
|
109
|
+
client = BrowserRuntimeClient("http://127.0.0.1:1", "super-secret")
|
|
110
|
+
self.assertNotIn("super-secret", repr(client))
|
|
111
|
+
|
|
112
|
+
# ---- idempotency-key passthrough -------------------------------------
|
|
113
|
+
|
|
114
|
+
def test_idempotency_key_passthrough_on_mutating_post(self) -> None:
|
|
115
|
+
seen = {}
|
|
116
|
+
|
|
117
|
+
def handler(req: _Handler) -> None:
|
|
118
|
+
body = json.loads(req.read_body() or b"{}")
|
|
119
|
+
seen["idempotency_key"] = req.headers.get("idempotency-key")
|
|
120
|
+
seen["profile"] = body.get("profile")
|
|
121
|
+
req.reply_json(
|
|
122
|
+
200,
|
|
123
|
+
{
|
|
124
|
+
"id": "session-1",
|
|
125
|
+
"profile": body.get("profile"),
|
|
126
|
+
"proxy": None,
|
|
127
|
+
"page_ids": [],
|
|
128
|
+
"created_at": "2026-09-23T00:00:00.000Z",
|
|
129
|
+
"last_used_at": "2026-09-23T00:00:00.000Z",
|
|
130
|
+
"execution_policy": {
|
|
131
|
+
"javascriptEvaluation": False,
|
|
132
|
+
"visionAssist": False,
|
|
133
|
+
"fingerprint": False,
|
|
134
|
+
"humanize": False,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
server = self._serve(handler)
|
|
140
|
+
client = BrowserRuntimeClient(server.base_url, "token")
|
|
141
|
+
session = client.create_session(
|
|
142
|
+
{"profile": "default", "proxy": None},
|
|
143
|
+
RequestOptions(idempotency_key="create-session-1"),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
self.assertEqual(session["id"], "session-1")
|
|
147
|
+
self.assertEqual(seen["idempotency_key"], "create-session-1")
|
|
148
|
+
self.assertEqual(seen["profile"], "default")
|
|
149
|
+
|
|
150
|
+
def test_idempotency_key_absent_when_not_supplied(self) -> None:
|
|
151
|
+
seen = {}
|
|
152
|
+
|
|
153
|
+
def handler(req: _Handler) -> None:
|
|
154
|
+
req.read_body()
|
|
155
|
+
seen["idempotency_key"] = req.headers.get("idempotency-key")
|
|
156
|
+
req.send_response(204)
|
|
157
|
+
req.end_headers()
|
|
158
|
+
|
|
159
|
+
server = self._serve(handler)
|
|
160
|
+
client = BrowserRuntimeClient(server.base_url, "token")
|
|
161
|
+
client.delete_session("11111111-1111-4111-8111-111111111111")
|
|
162
|
+
|
|
163
|
+
self.assertIsNone(seen["idempotency_key"])
|
|
164
|
+
|
|
165
|
+
# ---- 429 retryAfterMs ------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def test_429_surfaces_retry_after_ms_from_interface_error(self) -> None:
|
|
168
|
+
def handler(req: _Handler) -> None:
|
|
169
|
+
req.read_body()
|
|
170
|
+
req.reply_json(
|
|
171
|
+
429,
|
|
172
|
+
{
|
|
173
|
+
"error": {
|
|
174
|
+
"code": "resourceExhausted",
|
|
175
|
+
"layer": "interface",
|
|
176
|
+
"message": "too many requests",
|
|
177
|
+
"correlationId": "corr-1",
|
|
178
|
+
"commandId": None,
|
|
179
|
+
"retryable": True,
|
|
180
|
+
"retryAfterMs": 1500,
|
|
181
|
+
"reconciliationRequired": False,
|
|
182
|
+
"requiredCapability": None,
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
server = self._serve(handler)
|
|
188
|
+
client = BrowserRuntimeClient(server.base_url, "token")
|
|
189
|
+
|
|
190
|
+
with self.assertRaises(RuntimeClientError) as caught:
|
|
191
|
+
client.runtime_info()
|
|
192
|
+
|
|
193
|
+
error = caught.exception
|
|
194
|
+
self.assertEqual(error.kind, "http")
|
|
195
|
+
self.assertEqual(error.status, 429)
|
|
196
|
+
self.assertEqual(error.code, "resourceExhausted")
|
|
197
|
+
self.assertEqual(error.retry_after_ms, 1500)
|
|
198
|
+
self.assertTrue(error.retryable)
|
|
199
|
+
|
|
200
|
+
# ---- 409 needsReconciliation on submit_command ------------------------
|
|
201
|
+
|
|
202
|
+
def test_submit_command_409_needs_reconciliation_is_returned_not_raised(self) -> None:
|
|
203
|
+
def handler(req: _Handler) -> None:
|
|
204
|
+
req.read_body()
|
|
205
|
+
req.reply_json(
|
|
206
|
+
409,
|
|
207
|
+
{
|
|
208
|
+
"status": "needsReconciliation",
|
|
209
|
+
"commandId": "cmd-1",
|
|
210
|
+
"error": {
|
|
211
|
+
"code": "verificationFailed",
|
|
212
|
+
"message": "page state diverged",
|
|
213
|
+
"layer": "page",
|
|
214
|
+
"retryable": False,
|
|
215
|
+
},
|
|
216
|
+
"evidence": [],
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
server = self._serve(handler)
|
|
221
|
+
client = BrowserRuntimeClient(server.base_url, "token")
|
|
222
|
+
outcome = client.submit_command({"schemaVersion": 1, "commandId": "cmd-1"})
|
|
223
|
+
|
|
224
|
+
self.assertEqual(outcome["status"], "needsReconciliation")
|
|
225
|
+
self.assertEqual(outcome["commandId"], "cmd-1")
|
|
226
|
+
|
|
227
|
+
def test_submit_command_mismatched_http_mapping_raises_protocol_error(self) -> None:
|
|
228
|
+
def handler(req: _Handler) -> None:
|
|
229
|
+
req.read_body()
|
|
230
|
+
# needsReconciliation must map to 409; sending 200 is a protocol violation.
|
|
231
|
+
req.reply_json(200, {"status": "needsReconciliation", "commandId": "cmd-2", "error": {}, "evidence": []})
|
|
232
|
+
|
|
233
|
+
server = self._serve(handler)
|
|
234
|
+
client = BrowserRuntimeClient(server.base_url, "token")
|
|
235
|
+
|
|
236
|
+
with self.assertRaises(RuntimeClientError) as caught:
|
|
237
|
+
client.submit_command({"schemaVersion": 1, "commandId": "cmd-2"})
|
|
238
|
+
|
|
239
|
+
self.assertEqual(caught.exception.kind, "protocol")
|
|
240
|
+
|
|
241
|
+
# ---- status discriminator preserved -----------------------------------
|
|
242
|
+
|
|
243
|
+
def test_submit_command_status_discriminator_preserved(self) -> None:
|
|
244
|
+
for status, http_status, extra in (
|
|
245
|
+
("completed", 200, {"evidence": []}),
|
|
246
|
+
("policyDenied", 403, {"error": {"code": "policyDenied", "message": "x", "layer": "interface", "retryable": False}}),
|
|
247
|
+
(
|
|
248
|
+
"resourceExhausted",
|
|
249
|
+
429,
|
|
250
|
+
{
|
|
251
|
+
"error": {"code": "resourceExhausted", "message": "x", "layer": "interface", "retryable": True},
|
|
252
|
+
"retryAfterMs": 250,
|
|
253
|
+
},
|
|
254
|
+
),
|
|
255
|
+
):
|
|
256
|
+
with self.subTest(status=status):
|
|
257
|
+
payload = {"status": status, "commandId": "cmd-x", **extra}
|
|
258
|
+
|
|
259
|
+
def handler(req: _Handler, payload=payload, http_status=http_status) -> None:
|
|
260
|
+
req.read_body()
|
|
261
|
+
req.reply_json(http_status, payload)
|
|
262
|
+
|
|
263
|
+
server = self._serve(handler)
|
|
264
|
+
client = BrowserRuntimeClient(server.base_url, "token")
|
|
265
|
+
outcome = client.submit_command({"schemaVersion": 1, "commandId": "cmd-x"})
|
|
266
|
+
self.assertEqual(outcome["status"], status)
|
|
267
|
+
|
|
268
|
+
# ---- read_session (no single-id GET on the wire) -----------------------
|
|
269
|
+
|
|
270
|
+
def test_read_session_filters_the_list_endpoint_client_side(self) -> None:
|
|
271
|
+
def handler(req: _Handler) -> None:
|
|
272
|
+
req.read_body()
|
|
273
|
+
req.reply_json(
|
|
274
|
+
200,
|
|
275
|
+
[
|
|
276
|
+
{"id": "s1", "profile": "default"},
|
|
277
|
+
{"id": "s2", "profile": "default"},
|
|
278
|
+
],
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
server = self._serve(handler)
|
|
282
|
+
client = BrowserRuntimeClient(server.base_url, "token")
|
|
283
|
+
|
|
284
|
+
self.assertEqual(client.read_session("s2")["id"], "s2")
|
|
285
|
+
self.assertEqual(len(client.read_session()), 2)
|
|
286
|
+
with self.assertRaises(RuntimeClientError):
|
|
287
|
+
client.read_session("does-not-exist")
|
|
288
|
+
|
|
289
|
+
# ---- artifact verification ---------------------------------------------
|
|
290
|
+
|
|
291
|
+
def test_read_artifact_verifies_sha256_before_returning_bytes(self) -> None:
|
|
292
|
+
data = b"artifact-bytes-for-testing"
|
|
293
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
294
|
+
|
|
295
|
+
def handler(req: _Handler) -> None:
|
|
296
|
+
req.read_body()
|
|
297
|
+
req.send_response(200)
|
|
298
|
+
req.send_header("Content-Type", "application/octet-stream")
|
|
299
|
+
req.send_header("Content-Length", str(len(data)))
|
|
300
|
+
req.end_headers()
|
|
301
|
+
req.wfile.write(data)
|
|
302
|
+
|
|
303
|
+
server = self._serve(handler)
|
|
304
|
+
client = BrowserRuntimeClient(server.base_url, "token")
|
|
305
|
+
reference = {
|
|
306
|
+
"referenceId": "ref-1",
|
|
307
|
+
"artifactId": "artifact-1",
|
|
308
|
+
"sha256": digest,
|
|
309
|
+
"bytes": len(data),
|
|
310
|
+
"mediaType": "application/octet-stream",
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
self.assertEqual(client.read_artifact(reference), data)
|
|
314
|
+
|
|
315
|
+
def test_read_artifact_rejects_a_digest_mismatch(self) -> None:
|
|
316
|
+
data = b"tampered-or-wrong-bytes"
|
|
317
|
+
|
|
318
|
+
def handler(req: _Handler) -> None:
|
|
319
|
+
req.read_body()
|
|
320
|
+
req.send_response(200)
|
|
321
|
+
req.send_header("Content-Type", "application/octet-stream")
|
|
322
|
+
req.send_header("Content-Length", str(len(data)))
|
|
323
|
+
req.end_headers()
|
|
324
|
+
req.wfile.write(data)
|
|
325
|
+
|
|
326
|
+
server = self._serve(handler)
|
|
327
|
+
client = BrowserRuntimeClient(server.base_url, "token")
|
|
328
|
+
reference = {
|
|
329
|
+
"referenceId": "ref-2",
|
|
330
|
+
"artifactId": "artifact-2",
|
|
331
|
+
"sha256": "0" * 64,
|
|
332
|
+
"bytes": len(data),
|
|
333
|
+
"mediaType": "application/octet-stream",
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
with self.assertRaises(RuntimeClientError) as caught:
|
|
337
|
+
client.read_artifact(reference)
|
|
338
|
+
self.assertEqual(caught.exception.kind, "protocol")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
unittest.main()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""One live test: start the real ``bobby`` runtime from this worktree's
|
|
2
|
+
release build, mint a bootstrap credential with ``bobby init``, and drive
|
|
3
|
+
``runtime_info`` / ``create_session`` through the Python SDK over real HTTP.
|
|
4
|
+
|
|
5
|
+
Skipped cleanly (not failed) when ``BOBBY_CHROME_EXECUTABLE`` is unset, same
|
|
6
|
+
convention the Rust workspace tests use (crates/worker-pool/tests/chromium_worker.rs).
|
|
7
|
+
Never prints the bootstrap bearer: it is read from ``bobby init``'s stdout
|
|
8
|
+
and handed straight to the client.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import socket
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
import unittest
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
# See test_client.py: makes `bobby_browser` importable for
|
|
27
|
+
# `python3 -m unittest discover -s packages/python-sdk/tests` run straight
|
|
28
|
+
# from a checkout, with no `pip install` step first.
|
|
29
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
30
|
+
|
|
31
|
+
from bobby_browser import BrowserRuntimeClient # noqa: E402
|
|
32
|
+
|
|
33
|
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
34
|
+
BOBBY_BIN = REPO_ROOT / "target" / "release" / "bobby"
|
|
35
|
+
|
|
36
|
+
_SKIP_REASON = "BOBBY_CHROME_EXECUTABLE is unset; skipping the live runtime test"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _free_tcp_port() -> int:
|
|
40
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
41
|
+
probe.bind(("127.0.0.1", 0))
|
|
42
|
+
return probe.getsockname()[1]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _wait_for_healthz(base_url: str, deadline: float) -> None:
|
|
46
|
+
last_error: Exception | None = None
|
|
47
|
+
while time.monotonic() < deadline:
|
|
48
|
+
try:
|
|
49
|
+
with urllib.request.urlopen(f"{base_url}/healthz", timeout=2) as response:
|
|
50
|
+
if response.status == 200:
|
|
51
|
+
return
|
|
52
|
+
except (urllib.error.URLError, OSError) as error:
|
|
53
|
+
last_error = error
|
|
54
|
+
time.sleep(0.25)
|
|
55
|
+
raise AssertionError(f"bobby serve never became healthy: {last_error}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@unittest.skipUnless(os.environ.get("BOBBY_CHROME_EXECUTABLE"), _SKIP_REASON)
|
|
59
|
+
class LiveRuntimeTests(unittest.TestCase):
|
|
60
|
+
def test_runtime_info_and_create_session_over_real_http(self) -> None:
|
|
61
|
+
if not BOBBY_BIN.is_file():
|
|
62
|
+
self.skipTest(f"{BOBBY_BIN} not built; run: cargo build -p bobby-browser --release")
|
|
63
|
+
|
|
64
|
+
with tempfile.TemporaryDirectory(prefix="bobby-python-sdk-live-") as tmp:
|
|
65
|
+
tmp_path = Path(tmp)
|
|
66
|
+
# `bobby serve` does not auto-create every storage/browser
|
|
67
|
+
# directory (deploy/docker/entrypoint.sh pre-creates them for
|
|
68
|
+
# the same reason) -- the defaults are relative to the process
|
|
69
|
+
# cwd, which we pin to tmp_path below.
|
|
70
|
+
for relative in (
|
|
71
|
+
"data/profiles",
|
|
72
|
+
"data/uploads",
|
|
73
|
+
"data/downloads",
|
|
74
|
+
"data/artifacts",
|
|
75
|
+
"data/storage/checkpoints",
|
|
76
|
+
):
|
|
77
|
+
(tmp_path / relative).mkdir(parents=True, exist_ok=True)
|
|
78
|
+
|
|
79
|
+
bootstrap_path = tmp_path / "bootstrap.env"
|
|
80
|
+
init = subprocess.run(
|
|
81
|
+
[str(BOBBY_BIN), "init", "--path", str(bootstrap_path), "--force"],
|
|
82
|
+
cwd=tmp_path,
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
timeout=30,
|
|
86
|
+
)
|
|
87
|
+
self.assertEqual(init.returncode, 0, msg="bobby init failed (see stderr on the CI runner)")
|
|
88
|
+
bearer = init.stdout.strip()
|
|
89
|
+
self.assertTrue(bearer, "bobby init printed no bearer on stdout")
|
|
90
|
+
|
|
91
|
+
port = _free_tcp_port()
|
|
92
|
+
config_path = tmp_path / "config.toml"
|
|
93
|
+
config_path.write_text(
|
|
94
|
+
f'[server]\nhost = "127.0.0.1"\nport = {port}\n', encoding="utf-8"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
env = dict(os.environ)
|
|
98
|
+
env.setdefault("AUTOMATION_RUNTIME_BROWSER_SELECTION", json.dumps({"preference": {"mode": "managedChromium"}}))
|
|
99
|
+
|
|
100
|
+
serve_stdout = open(tmp_path / "serve.stdout.log", "wb")
|
|
101
|
+
serve_stderr = open(tmp_path / "serve.stderr.log", "wb")
|
|
102
|
+
process = subprocess.Popen(
|
|
103
|
+
[str(BOBBY_BIN), "serve", "--config", str(config_path), "--bootstrap-env", str(bootstrap_path)],
|
|
104
|
+
cwd=tmp_path,
|
|
105
|
+
env=env,
|
|
106
|
+
stdout=serve_stdout,
|
|
107
|
+
stderr=serve_stderr,
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
base_url = f"http://127.0.0.1:{port}"
|
|
111
|
+
_wait_for_healthz(base_url, deadline=time.monotonic() + 30)
|
|
112
|
+
|
|
113
|
+
client = BrowserRuntimeClient(base_url, bearer)
|
|
114
|
+
info = client.runtime_info()
|
|
115
|
+
self.assertIn("version", info)
|
|
116
|
+
self.assertIsInstance(info["capabilities"], list)
|
|
117
|
+
|
|
118
|
+
session = client.create_session({"profile": "default", "proxy": None})
|
|
119
|
+
self.assertTrue(session.get("id"))
|
|
120
|
+
self.assertEqual(session.get("profile"), "default")
|
|
121
|
+
finally:
|
|
122
|
+
process.terminate()
|
|
123
|
+
try:
|
|
124
|
+
process.wait(timeout=10)
|
|
125
|
+
except subprocess.TimeoutExpired:
|
|
126
|
+
process.kill()
|
|
127
|
+
process.wait(timeout=10)
|
|
128
|
+
with contextlib.suppress(Exception):
|
|
129
|
+
serve_stdout.close()
|
|
130
|
+
with contextlib.suppress(Exception):
|
|
131
|
+
serve_stderr.close()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
unittest.main()
|