solari-browserbase-compat 0.1.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.
- solari_browserbase_compat-0.1.0/.gitignore +5 -0
- solari_browserbase_compat-0.1.0/COMPAT.md +68 -0
- solari_browserbase_compat-0.1.0/LICENSE +21 -0
- solari_browserbase_compat-0.1.0/PKG-INFO +44 -0
- solari_browserbase_compat-0.1.0/README.md +28 -0
- solari_browserbase_compat-0.1.0/pyproject.toml +28 -0
- solari_browserbase_compat-0.1.0/solari_browserbase/__init__.py +22 -0
- solari_browserbase_compat-0.1.0/solari_browserbase/client.py +378 -0
- solari_browserbase_compat-0.1.0/solari_browserbase/errors.py +27 -0
- solari_browserbase_compat-0.1.0/solari_browserbase/mapping.py +128 -0
- solari_browserbase_compat-0.1.0/tests/live_staging.py +52 -0
- solari_browserbase_compat-0.1.0/tests/test_compat.py +273 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Browserbase compatibility — what works, what differs, what raises
|
|
2
|
+
|
|
3
|
+
`solari-browserbase-compat` gives Solari Browser the shape of the Browserbase Python SDK, so code written against
|
|
4
|
+
`Browserbase(api_key=...)` runs against Solari with the import and the key changed. This page is the full list.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from solari_browserbase import Browserbase
|
|
8
|
+
from playwright.sync_api import sync_playwright
|
|
9
|
+
|
|
10
|
+
bb = Browserbase(api_key=os.environ["SOLARI_API_KEY"]) # slr_… key
|
|
11
|
+
session = bb.sessions.create(browser_settings={"recordSession": True})
|
|
12
|
+
with sync_playwright() as p:
|
|
13
|
+
browser = p.chromium.connect_over_cdp(session.connect_url)
|
|
14
|
+
# … drive the page …
|
|
15
|
+
browser.close()
|
|
16
|
+
bb.sessions.update(session.id, status="REQUEST_RELEASE")
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`connect_url` is a raw CDP websocket URL, so any CDP client works and **nothing needs a pinned Playwright version**.
|
|
20
|
+
Keep it private: the session id inside it is the credential. `AsyncBrowserbase` has the same methods (each call runs
|
|
21
|
+
the sync client in a worker thread). Parameter names are snake_case at the top level (`keep_alive`, `user_metadata`,
|
|
22
|
+
`browser_settings`); keys inside `browser_settings` stay camelCase, as in Browserbase's Python SDK.
|
|
23
|
+
|
|
24
|
+
## Keys and endpoint
|
|
25
|
+
| | |
|
|
26
|
+
|---|---|
|
|
27
|
+
| Key | `api_key` argument, else `SOLARI_API_KEY`, else `BROWSERBASE_API_KEY`. **A key that does not start with `slr_` is never used or sent** (it is probably a Browserbase secret): it is skipped with a warning that does not print it, and if no `slr_` key remains the constructor throws. |
|
|
28
|
+
| Endpoint | `base_url` argument or `SOLARI_BASE_URL`; default `https://api.getsolari.com`. |
|
|
29
|
+
| `project_id` | Accepted and ignored: Solari keys belong to an organisation, there are no projects. |
|
|
30
|
+
|
|
31
|
+
## Works
|
|
32
|
+
| Browserbase | Here |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `sessions.create` | `POST /sessions`; returns `connect_url` (CDP), `id`, `status: "RUNNING"`, `expiresAt`, `region`, `keepAlive`, `contextId`. A create is retried safely (idempotency key). |
|
|
35
|
+
| `sessions.retrieve`, `sessions.list(status=...)` | Solari `active/released/expired/unknown` map to `RUNNING/COMPLETED/TIMED_OUT/ERROR`. `proxyBytes` is `0`, `startedAt` is the create time. |
|
|
36
|
+
| `sessions.update(id, status="REQUEST_RELEASE")` | Releases the session. |
|
|
37
|
+
| `proxies: true`, `proxies: [{ type: "browserbase", geolocation }]` | A managed proxy; `geolocation.country/state/city` map across (a proxy needs the stealth browser, so `stealth` is switched on for you). `{ type: "none" }` means no proxy. |
|
|
38
|
+
| `browserSettings.verified`, `advancedStealth` | Solari's stealth browser. |
|
|
39
|
+
| `browserSettings.solveCaptchas: true` | Solari captcha solving (stealth browser, plan-gated). **Off unless you pass `true`**: Browserbase turns it on by default. |
|
|
40
|
+
| `browserSettings.recordSession: true` | Session recording. **Off unless you pass `true`**: Browserbase records by default. Read it back with `sessions.recording.retrieve(id)` (rrweb events). |
|
|
41
|
+
| `browserSettings.allowedDomains` | Egress allowlist (enterprise plan, stealth browser); a plan error comes back as an `APIError` with `code: "FeatureRequiresPlan"`. |
|
|
42
|
+
| `browserSettings.context: { id, persist }`, `contexts.create/retrieve/delete` | A Context is a Solari **profile**. See [Contexts](#contexts). |
|
|
43
|
+
| `sessions.debug(id, expires_in=...)` | A view-only live-view link (`debuggerUrl` and `debuggerFullscreenUrl` are the same link; `pages` is empty; `wsUrl` is the CDP URL). |
|
|
44
|
+
|
|
45
|
+
## Differs (documented, not errors)
|
|
46
|
+
- **Recording is a DOM-event log, not video.** `sessions.recording.retrieve(id)` returns rrweb events once the replay has been uploaded (a few seconds after release; until then it throws `APIError` with `code: "ReplayPending"`). There is no HLS playlist and no MP4.
|
|
47
|
+
- **Contexts store cookies and localStorage only** (Playwright `storageState`), not IndexedDB, service workers or autofill data.
|
|
48
|
+
- **No choice of operating system or viewport at create time.** Set the viewport from your automation client.
|
|
49
|
+
- **`keepAlive`**: a disconnected session is released about 45 s after its socket closes (reconnect with the same `connect_url` inside that window). Sessions end at your plan's maximum session length.
|
|
50
|
+
- **`timeout`, `userMetadata`, `keepAlive: true`, `viewport`, `blockAds: true`, `logSession: true`, `os`** are accepted with a one-line warning and have no effect. Pass `strict=True` to make them throw instead.
|
|
51
|
+
|
|
52
|
+
## Contexts
|
|
53
|
+
```python
|
|
54
|
+
ctx = bb.contexts.create(name="shop-login")
|
|
55
|
+
s = bb.sessions.create(browser_settings={"context": {"id": ctx.id, "persist": True}})
|
|
56
|
+
browser = p.chromium.connect_over_cdp(s.connect_url)
|
|
57
|
+
# First run: log in, then release; the context is saved when you release through this client.
|
|
58
|
+
bb.sessions.update(s.id, status="REQUEST_RELEASE")
|
|
59
|
+
|
|
60
|
+
# Later: the saved login comes back as `storage_state`. Create your browser context with it:
|
|
61
|
+
s2 = bb.sessions.create(browser_settings={"context": {"id": ctx.id}})
|
|
62
|
+
b2 = p.chromium.connect_over_cdp(s2.connect_url)
|
|
63
|
+
page = b2.new_context(storage_state=s2.storage_state).new_page()
|
|
64
|
+
```
|
|
65
|
+
Two differences from Browserbase: Solari applies a saved context only to a browser context created *with* it (use `new_context(storage_state=...)`, not `browser.contexts[0]`), and `persist: True` saves when you release **through the same client object** (it is remembered in memory).
|
|
66
|
+
|
|
67
|
+
## Raises `SolariCompatUnsupported`
|
|
68
|
+
Extensions (`extension_id`), the Selenium endpoint (`seleniumRemoteUrl` is always empty), downloads (`sessions.downloads`) and uploads (`sessions.uploads`) APIs, video replays (`sessions.replays`), session logs (`sessions.logs`), external/bring-your-own proxies, per-domain proxy rules (`domainPattern`), custom CA certificates, `ignoreCertificateErrors: true`, custom captcha selectors, `os: "mobile" | "tablet"`, a `region` other than `us-west-2` (or `us-west`), `sessions.list(q=...)` (metadata queries), `contexts.update`, and the separate `projects`, `extensions`, `agents`, `fetch_api` and `search` resources.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Solari
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: solari-browserbase-compat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Browserbase-style Python SDK surface for Solari Browser: sessions, connect_url (CDP), contexts, live view, replay events.
|
|
5
|
+
Project-URL: Homepage, https://getsolari.com
|
|
6
|
+
Author: Solari
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: browser,browserbase,cdp,compat,playwright,solari
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: httpx>=0.24
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
14
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# solari-browserbase-compat
|
|
18
|
+
|
|
19
|
+
A Browserbase-shaped Python SDK for [Solari Browser](https://getsolari.com): change the import and the key, keep your code.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install solari-browserbase-compat
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import os
|
|
27
|
+
from solari_browserbase import Browserbase
|
|
28
|
+
from playwright.sync_api import sync_playwright
|
|
29
|
+
|
|
30
|
+
bb = Browserbase(api_key=os.environ["SOLARI_API_KEY"]) # slr_… key
|
|
31
|
+
session = bb.sessions.create()
|
|
32
|
+
with sync_playwright() as p:
|
|
33
|
+
browser = p.chromium.connect_over_cdp(session.connect_url)
|
|
34
|
+
page = browser.contexts[0].pages[0] if browser.contexts[0].pages else browser.contexts[0].new_page()
|
|
35
|
+
page.goto("https://example.com")
|
|
36
|
+
browser.close()
|
|
37
|
+
bb.sessions.update(session.id, status="REQUEST_RELEASE")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Sessions, contexts, live view and recording are supported; extensions, downloads/uploads APIs, video replays,
|
|
41
|
+
bring-your-own proxies and Selenium raise `SolariCompatUnsupported`. See [COMPAT.md](./COMPAT.md) for the full list
|
|
42
|
+
and every difference. Python 3.9+. No Playwright version pin is needed: `connect_url` is a plain CDP endpoint.
|
|
43
|
+
|
|
44
|
+
Licence: MIT.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# solari-browserbase-compat
|
|
2
|
+
|
|
3
|
+
A Browserbase-shaped Python SDK for [Solari Browser](https://getsolari.com): change the import and the key, keep your code.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install solari-browserbase-compat
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import os
|
|
11
|
+
from solari_browserbase import Browserbase
|
|
12
|
+
from playwright.sync_api import sync_playwright
|
|
13
|
+
|
|
14
|
+
bb = Browserbase(api_key=os.environ["SOLARI_API_KEY"]) # slr_… key
|
|
15
|
+
session = bb.sessions.create()
|
|
16
|
+
with sync_playwright() as p:
|
|
17
|
+
browser = p.chromium.connect_over_cdp(session.connect_url)
|
|
18
|
+
page = browser.contexts[0].pages[0] if browser.contexts[0].pages else browser.contexts[0].new_page()
|
|
19
|
+
page.goto("https://example.com")
|
|
20
|
+
browser.close()
|
|
21
|
+
bb.sessions.update(session.id, status="REQUEST_RELEASE")
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Sessions, contexts, live view and recording are supported; extensions, downloads/uploads APIs, video replays,
|
|
25
|
+
bring-your-own proxies and Selenium raise `SolariCompatUnsupported`. See [COMPAT.md](./COMPAT.md) for the full list
|
|
26
|
+
and every difference. Python 3.9+. No Playwright version pin is needed: `connect_url` is a plain CDP endpoint.
|
|
27
|
+
|
|
28
|
+
Licence: MIT.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "solari-browserbase-compat"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Browserbase-style Python SDK surface for Solari Browser: sessions, connect_url (CDP), contexts, live view, replay events."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Solari" }]
|
|
13
|
+
keywords = ["solari", "browserbase", "browser", "playwright", "cdp", "compat"]
|
|
14
|
+
# Pure HTTP: no Playwright / patchright dependency. connect_url is a raw CDP endpoint, so any CDP client works
|
|
15
|
+
# without a pinned version.
|
|
16
|
+
dependencies = ["httpx>=0.24"]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["pytest>=7", "ruff>=0.1"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://getsolari.com"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["solari_browserbase"]
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 110
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Browserbase-style Python SDK surface for Solari Browser. See COMPAT.md for what is and is not supported."""
|
|
2
|
+
from .client import (
|
|
3
|
+
AsyncBrowserbase,
|
|
4
|
+
Browserbase,
|
|
5
|
+
Context,
|
|
6
|
+
ContextCreateResponse,
|
|
7
|
+
DEFAULT_BASE_URL,
|
|
8
|
+
Session,
|
|
9
|
+
SessionCreateResponse,
|
|
10
|
+
SessionLiveURLs,
|
|
11
|
+
SessionRetrieveResponse,
|
|
12
|
+
)
|
|
13
|
+
from .errors import APIError, BrowserbaseError, SolariCompatUnsupported
|
|
14
|
+
from .mapping import map_create_params, map_status
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AsyncBrowserbase", "Browserbase", "APIError", "BrowserbaseError", "SolariCompatUnsupported",
|
|
20
|
+
"Context", "ContextCreateResponse", "Session", "SessionCreateResponse", "SessionRetrieveResponse",
|
|
21
|
+
"SessionLiveURLs", "DEFAULT_BASE_URL", "map_create_params", "map_status",
|
|
22
|
+
]
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import gzip
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
import warnings as _warnings
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .errors import APIError, BrowserbaseError, SolariCompatUnsupported
|
|
16
|
+
from .mapping import map_create_params, map_status
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE_URL = "https://api.getsolari.com"
|
|
19
|
+
REGION = "us-west-2" # reported on Browserbase-shaped objects; Solari's single region (us-west)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Session:
|
|
24
|
+
id: str
|
|
25
|
+
created_at: str
|
|
26
|
+
updated_at: str
|
|
27
|
+
project_id: str
|
|
28
|
+
started_at: str
|
|
29
|
+
expires_at: str
|
|
30
|
+
status: str
|
|
31
|
+
proxy_bytes: int
|
|
32
|
+
keep_alive: bool
|
|
33
|
+
region: str
|
|
34
|
+
ended_at: Optional[str] = None
|
|
35
|
+
context_id: Optional[str] = None
|
|
36
|
+
user_metadata: Optional[Dict[str, Any]] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class SessionCreateResponse(Session):
|
|
41
|
+
# CDP websocket URL: playwright `connect_over_cdp(connect_url)` / any CDP client.
|
|
42
|
+
connect_url: str = ""
|
|
43
|
+
selenium_remote_url: str = "" # always empty: no WebDriver endpoint
|
|
44
|
+
signing_key: str = "" # always empty: the session id in the URL is the credential
|
|
45
|
+
# Solari extension. For a session created with browserSettings.context: the saved cookies/localStorage.
|
|
46
|
+
# Solari applies a saved context only to a browser context created with it, so create yours with
|
|
47
|
+
# browser.new_context(storage_state=session.storage_state). None when no context was requested.
|
|
48
|
+
storage_state: Optional[Dict[str, Any]] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
SessionRetrieveResponse = SessionCreateResponse
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class SessionLiveURLs:
|
|
56
|
+
debugger_fullscreen_url: str
|
|
57
|
+
debugger_url: str
|
|
58
|
+
ws_url: str
|
|
59
|
+
pages: List[Dict[str, Any]] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Context:
|
|
64
|
+
id: str
|
|
65
|
+
name: Optional[str] = None
|
|
66
|
+
created_at: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ContextCreateResponse:
|
|
71
|
+
id: str
|
|
72
|
+
public_key: str = "" # empty: Solari profiles are stored server-side
|
|
73
|
+
cipher_algorithm: str = ""
|
|
74
|
+
initialization_vector_size: int = 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _Unsupported:
|
|
78
|
+
"""Every attribute is a callable that raises SolariCompatUnsupported."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, feature: str, hint: str) -> None:
|
|
81
|
+
self._feature = feature
|
|
82
|
+
self._hint = hint
|
|
83
|
+
|
|
84
|
+
def __getattr__(self, name: str) -> Callable[..., Any]:
|
|
85
|
+
if name.startswith("__"):
|
|
86
|
+
raise AttributeError(name)
|
|
87
|
+
|
|
88
|
+
def _raise(*_a: Any, **_k: Any) -> Any:
|
|
89
|
+
raise SolariCompatUnsupported(f"{self._feature}.{name}", self._hint)
|
|
90
|
+
|
|
91
|
+
return _raise
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_api_key(explicit: Optional[str], warn: Callable[[str], None]) -> str:
|
|
95
|
+
for name, v in (
|
|
96
|
+
("api_key", explicit),
|
|
97
|
+
("SOLARI_API_KEY", os.environ.get("SOLARI_API_KEY")),
|
|
98
|
+
("BROWSERBASE_API_KEY", os.environ.get("BROWSERBASE_API_KEY")),
|
|
99
|
+
):
|
|
100
|
+
if not v:
|
|
101
|
+
continue
|
|
102
|
+
if v.startswith("slr_"):
|
|
103
|
+
return v
|
|
104
|
+
# Never echo any part of the value: it is probably a Browserbase secret.
|
|
105
|
+
warn(f"{name} does not look like a Solari key (expected the slr_ prefix); ignoring it. It was not sent anywhere.")
|
|
106
|
+
raise BrowserbaseError("No Solari API key: pass api_key or set SOLARI_API_KEY (keys start with slr_)")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _default_warn(msg: str) -> None:
|
|
110
|
+
_warnings.warn(f"[solari-browserbase-compat] {msg}", stacklevel=3)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _session(row: Dict[str, Any], *, status: Optional[str] = None, keep_alive: bool = False, context_id: Optional[str] = None) -> Session:
|
|
114
|
+
created = row.get("createdAt") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
115
|
+
st = status or map_status(row.get("status"))
|
|
116
|
+
return Session(
|
|
117
|
+
id=row["id"],
|
|
118
|
+
created_at=created,
|
|
119
|
+
updated_at=created,
|
|
120
|
+
project_id="",
|
|
121
|
+
started_at=created,
|
|
122
|
+
ended_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) if st in ("COMPLETED", "TIMED_OUT") else None,
|
|
123
|
+
expires_at=row.get("expiresAt") or created,
|
|
124
|
+
status=st,
|
|
125
|
+
proxy_bytes=0,
|
|
126
|
+
keep_alive=keep_alive,
|
|
127
|
+
region=REGION,
|
|
128
|
+
context_id=context_id,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _Http:
|
|
133
|
+
def __init__(self, api_key: str, base_url: str, timeout: float, max_retries: int, transport: Optional[httpx.BaseTransport]) -> None:
|
|
134
|
+
self.api_key = api_key
|
|
135
|
+
self.base_url = base_url.rstrip("/")
|
|
136
|
+
self.max_retries = max_retries
|
|
137
|
+
self.client = httpx.Client(timeout=timeout, transport=transport)
|
|
138
|
+
|
|
139
|
+
def request(self, method: str, path: str, body: Any = None, idempotency_key: Optional[str] = None) -> httpx.Response:
|
|
140
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
|
141
|
+
if idempotency_key:
|
|
142
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
143
|
+
replay_safe = method in ("GET", "DELETE") or idempotency_key is not None
|
|
144
|
+
last: Optional[Exception] = None
|
|
145
|
+
for attempt in range(self.max_retries + 1):
|
|
146
|
+
try:
|
|
147
|
+
res = self.client.request(method, f"{self.base_url}{path}", headers=headers, content=None if body is None else json.dumps(body))
|
|
148
|
+
if res.is_success or not replay_safe or res.status_code not in (502, 503, 504) or attempt == self.max_retries:
|
|
149
|
+
return res
|
|
150
|
+
except httpx.TransportError as e:
|
|
151
|
+
if not replay_safe or attempt == self.max_retries:
|
|
152
|
+
raise
|
|
153
|
+
last = e
|
|
154
|
+
time.sleep(0.3 * (attempt + 1))
|
|
155
|
+
raise last or BrowserbaseError("request failed")
|
|
156
|
+
|
|
157
|
+
def json(self, method: str, path: str, body: Any = None, idempotency_key: Optional[str] = None) -> Any:
|
|
158
|
+
res = self.request(method, path, body, idempotency_key)
|
|
159
|
+
if res.status_code == 204:
|
|
160
|
+
return None
|
|
161
|
+
text = res.text
|
|
162
|
+
try:
|
|
163
|
+
parsed = json.loads(text) if text else None
|
|
164
|
+
except ValueError:
|
|
165
|
+
parsed = None
|
|
166
|
+
if not res.is_success:
|
|
167
|
+
o = parsed if isinstance(parsed, dict) else {}
|
|
168
|
+
raise APIError(res.status_code, f"Solari {method} {path}: {res.status_code} {o.get('error') or text[:200]}", o.get("code"), parsed)
|
|
169
|
+
return parsed
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _Recording:
|
|
173
|
+
def __init__(self, http: _Http) -> None:
|
|
174
|
+
self._http = http
|
|
175
|
+
|
|
176
|
+
def retrieve(self, id: str) -> List[Any]:
|
|
177
|
+
"""The session's DOM recording as a list of rrweb events (needs ``recordSession: True``; available a few seconds after release)."""
|
|
178
|
+
meta = self._http.json("GET", f"/sessions/{id}/replay-url")
|
|
179
|
+
url = (meta or {}).get("url")
|
|
180
|
+
if not url:
|
|
181
|
+
raise BrowserbaseError("Solari returned no replay URL")
|
|
182
|
+
r = httpx.get(url, timeout=60)
|
|
183
|
+
if not r.is_success:
|
|
184
|
+
raise APIError(r.status_code, f"replay download failed: {r.status_code}")
|
|
185
|
+
data = r.content
|
|
186
|
+
if data[:2] == b"\x1f\x8b":
|
|
187
|
+
data = gzip.decompress(data)
|
|
188
|
+
return [json.loads(line) for line in data.decode("utf-8").split("\n") if line.strip()]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _Sessions:
|
|
192
|
+
def __init__(self, bb: "Browserbase") -> None:
|
|
193
|
+
self._bb = bb
|
|
194
|
+
self.recording = _Recording(bb._http)
|
|
195
|
+
self.replays = _Unsupported("sessions.replays", "Solari records DOM events, not video; use sessions.recording.retrieve")
|
|
196
|
+
self.logs = _Unsupported("sessions.logs", "session command logs are not available")
|
|
197
|
+
self.downloads = _Unsupported("sessions.downloads", "file downloads cannot be retrieved through the API")
|
|
198
|
+
self.uploads = _Unsupported("sessions.uploads", "use your automation client's set_input_files")
|
|
199
|
+
|
|
200
|
+
def create(
|
|
201
|
+
self,
|
|
202
|
+
*,
|
|
203
|
+
project_id: Optional[str] = None,
|
|
204
|
+
extension_id: Optional[str] = None,
|
|
205
|
+
browser_settings: Optional[Dict[str, Any]] = None,
|
|
206
|
+
timeout: Optional[int] = None,
|
|
207
|
+
keep_alive: Optional[bool] = None,
|
|
208
|
+
proxies: Any = None,
|
|
209
|
+
proxy_settings: Optional[Dict[str, Any]] = None,
|
|
210
|
+
region: Optional[str] = None,
|
|
211
|
+
user_metadata: Optional[Dict[str, Any]] = None,
|
|
212
|
+
) -> SessionCreateResponse:
|
|
213
|
+
m = map_create_params(
|
|
214
|
+
extension_id=extension_id, browser_settings=browser_settings, timeout=timeout, keep_alive=keep_alive,
|
|
215
|
+
proxies=proxies, proxy_settings=proxy_settings, region=region, user_metadata=user_metadata, strict=self._bb._strict,
|
|
216
|
+
)
|
|
217
|
+
for w in m.warnings:
|
|
218
|
+
self._bb._warn(w)
|
|
219
|
+
data = self._bb._http.json("POST", "/sessions", m.body or None, idempotency_key=str(uuid.uuid4()))
|
|
220
|
+
if not data or not data.get("sessionId") or not data.get("cdpEndpoint"):
|
|
221
|
+
raise BrowserbaseError(f"Solari returned an unexpected session response: {data!r}")
|
|
222
|
+
if m.persist_profile_id:
|
|
223
|
+
self._bb._persist[data["sessionId"]] = m.persist_profile_id
|
|
224
|
+
ctx = ((browser_settings or {}).get("context") or {}).get("id")
|
|
225
|
+
base = _session(
|
|
226
|
+
{"id": data["sessionId"], "createdAt": None, "expiresAt": data.get("expiresAt")},
|
|
227
|
+
status="RUNNING", keep_alive=keep_alive is True, context_id=ctx,
|
|
228
|
+
)
|
|
229
|
+
out = SessionCreateResponse(**base.__dict__, connect_url=data["cdpEndpoint"])
|
|
230
|
+
if ctx:
|
|
231
|
+
state = data.get("storageState")
|
|
232
|
+
url = (data.get("storageStateUrl") or {}).get("url")
|
|
233
|
+
if state is None and url:
|
|
234
|
+
try:
|
|
235
|
+
r = self._bb._http.client.get(url, timeout=15)
|
|
236
|
+
state = r.json() if r.is_success else None
|
|
237
|
+
except (httpx.HTTPError, ValueError):
|
|
238
|
+
state = None
|
|
239
|
+
if state is None:
|
|
240
|
+
self._bb._warn("could not download the saved context; the session starts without it")
|
|
241
|
+
out.storage_state = state
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
def retrieve(self, id: str) -> SessionRetrieveResponse:
|
|
245
|
+
row = self._bb._http.json("GET", f"/sessions/{id}") or {}
|
|
246
|
+
row.setdefault("id", id)
|
|
247
|
+
return SessionCreateResponse(**_session(row).__dict__, connect_url=row.get("cdpEndpoint") or "")
|
|
248
|
+
|
|
249
|
+
def list(self, *, status: Optional[str] = None, q: Optional[str] = None) -> List[Session]:
|
|
250
|
+
if q:
|
|
251
|
+
raise SolariCompatUnsupported("sessions.list q (user_metadata query)", "metadata is not stored")
|
|
252
|
+
data = self._bb._http.json("GET", "/sessions") or {}
|
|
253
|
+
out = [_session(r) for r in data.get("sessions", [])]
|
|
254
|
+
return [s for s in out if s.status == status] if status else out
|
|
255
|
+
|
|
256
|
+
def update(self, id: str, *, status: str, project_id: Optional[str] = None) -> Session:
|
|
257
|
+
"""``status="REQUEST_RELEASE"`` releases the session (saving the context first when it was created with persist)."""
|
|
258
|
+
if status != "REQUEST_RELEASE":
|
|
259
|
+
raise SolariCompatUnsupported(f"sessions.update status {status}", "only REQUEST_RELEASE exists")
|
|
260
|
+
profile_id = self._bb._persist.get(id)
|
|
261
|
+
if profile_id:
|
|
262
|
+
self._bb._http.json("POST", f"/sessions/{id}/save-profile", {"profileId": profile_id})
|
|
263
|
+
self._bb._persist.pop(id, None)
|
|
264
|
+
self._bb._http.json("DELETE", f"/sessions/{id}")
|
|
265
|
+
return _session({"id": id, "status": "released"}, status="COMPLETED")
|
|
266
|
+
|
|
267
|
+
def debug(self, id: str, *, expires_in: Optional[int] = None) -> SessionLiveURLs:
|
|
268
|
+
"""Live-view links. Both URLs are the same view-only share link; ``pages`` is empty."""
|
|
269
|
+
share = self._bb._http.json("POST", f"/sessions/{id}/shares", {} if expires_in is None else {"ttlSeconds": expires_in})
|
|
270
|
+
row = self._bb._http.json("GET", f"/sessions/{id}") or {}
|
|
271
|
+
return SessionLiveURLs(share["url"], share["url"], row.get("cdpEndpoint") or "", [])
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class _Contexts:
|
|
275
|
+
def __init__(self, http: _Http) -> None:
|
|
276
|
+
self._http = http
|
|
277
|
+
|
|
278
|
+
def create(self, *, project_id: Optional[str] = None, name: Optional[str] = None) -> ContextCreateResponse:
|
|
279
|
+
"""A Context is a Solari profile (cookies + localStorage)."""
|
|
280
|
+
p = self._http.json("POST", "/profiles", {"name": name or f"context-{uuid.uuid4().hex[:8]}"})
|
|
281
|
+
return ContextCreateResponse(id=p["id"])
|
|
282
|
+
|
|
283
|
+
def retrieve(self, id: str) -> Context:
|
|
284
|
+
for p in self._http.json("GET", "/profiles") or []:
|
|
285
|
+
if p.get("id") == id:
|
|
286
|
+
return Context(id=p["id"], name=p.get("name"), created_at=p.get("createdAt"))
|
|
287
|
+
raise APIError(404, f"context {id} not found", "NotFound")
|
|
288
|
+
|
|
289
|
+
def update(self, *_a: Any, **_k: Any) -> Any:
|
|
290
|
+
raise SolariCompatUnsupported("contexts.update", "contexts are saved when a persist session is released")
|
|
291
|
+
|
|
292
|
+
def delete(self, id: str) -> None:
|
|
293
|
+
self._http.json("DELETE", f"/profiles/{id}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class Browserbase:
|
|
297
|
+
"""Browserbase-shaped client for Solari Browser. ``connect_url`` is a raw CDP websocket URL."""
|
|
298
|
+
|
|
299
|
+
def __init__(
|
|
300
|
+
self,
|
|
301
|
+
*,
|
|
302
|
+
api_key: Optional[str] = None,
|
|
303
|
+
base_url: Optional[str] = None,
|
|
304
|
+
strict: bool = False,
|
|
305
|
+
on_warning: Optional[Callable[[str], None]] = None,
|
|
306
|
+
max_retries: int = 2,
|
|
307
|
+
timeout: float = 60.0,
|
|
308
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
309
|
+
) -> None:
|
|
310
|
+
self._warn = on_warning or _default_warn
|
|
311
|
+
self._strict = strict
|
|
312
|
+
key = _resolve_api_key(api_key, self._warn)
|
|
313
|
+
self._http = _Http(key, base_url or os.environ.get("SOLARI_BASE_URL") or DEFAULT_BASE_URL, timeout, max_retries, transport)
|
|
314
|
+
self._persist: Dict[str, str] = {} # session id -> profile to save on release (in-process only)
|
|
315
|
+
self._api_key = key
|
|
316
|
+
self.sessions = _Sessions(self)
|
|
317
|
+
self.contexts = _Contexts(self._http)
|
|
318
|
+
self.extensions = _Unsupported("extensions", "browser extensions cannot be loaded")
|
|
319
|
+
self.projects = _Unsupported("projects", "Solari keys are organisation-scoped; there are no projects")
|
|
320
|
+
self.agents = _Unsupported("agents", "Browserbase Agents is a separate product")
|
|
321
|
+
self.fetch_api = _Unsupported("fetch_api", "Browserbase Fetch is a separate product")
|
|
322
|
+
self.search = _Unsupported("search", "Browserbase Search is a separate product")
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def api_key(self) -> str:
|
|
326
|
+
return self._api_key
|
|
327
|
+
|
|
328
|
+
def __repr__(self) -> str:
|
|
329
|
+
return f"Browserbase(base_url={self._http.base_url!r})"
|
|
330
|
+
|
|
331
|
+
def close(self) -> None:
|
|
332
|
+
self._http.client.close()
|
|
333
|
+
|
|
334
|
+
def __enter__(self) -> "Browserbase":
|
|
335
|
+
return self
|
|
336
|
+
|
|
337
|
+
def __exit__(self, *_a: Any) -> None:
|
|
338
|
+
self.close()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class _AsyncProxy:
|
|
342
|
+
def __init__(self, target: Any) -> None:
|
|
343
|
+
self._t = target
|
|
344
|
+
|
|
345
|
+
def __getattr__(self, name: str) -> Any:
|
|
346
|
+
v = getattr(self._t, name)
|
|
347
|
+
if callable(v):
|
|
348
|
+
async def _call(*a: Any, **k: Any) -> Any:
|
|
349
|
+
return await asyncio.to_thread(v, *a, **k)
|
|
350
|
+
|
|
351
|
+
return _call
|
|
352
|
+
if v.__class__.__module__.startswith("solari_browserbase") and not hasattr(v, "__dataclass_fields__"):
|
|
353
|
+
return _AsyncProxy(v)
|
|
354
|
+
return v
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class AsyncBrowserbase:
|
|
358
|
+
"""Async twin: every call runs the sync client in a worker thread (no extra dependencies)."""
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def api_key(self) -> str:
|
|
362
|
+
return self._sync.api_key
|
|
363
|
+
|
|
364
|
+
def __repr__(self) -> str:
|
|
365
|
+
return repr(self._sync).replace("Browserbase(", "AsyncBrowserbase(", 1)
|
|
366
|
+
|
|
367
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
368
|
+
self._sync = Browserbase(**kwargs)
|
|
369
|
+
self.sessions = _AsyncProxy(self._sync.sessions)
|
|
370
|
+
self.contexts = _AsyncProxy(self._sync.contexts)
|
|
371
|
+
self.extensions = self._sync.extensions
|
|
372
|
+
self.projects = self._sync.projects
|
|
373
|
+
self.agents = self._sync.agents
|
|
374
|
+
self.fetch_api = self._sync.fetch_api
|
|
375
|
+
self.search = self._sync.search
|
|
376
|
+
|
|
377
|
+
async def close(self) -> None:
|
|
378
|
+
await asyncio.to_thread(self._sync.close)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BrowserbaseError(Exception):
|
|
7
|
+
"""Base class for everything this package raises."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIError(BrowserbaseError):
|
|
11
|
+
"""The gateway answered with a non-2xx status. ``code`` is Solari's machine-readable error code when present."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, status: int, message: str, code: Optional[str] = None, body: Any = None) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.status = status
|
|
16
|
+
self.status_code = status
|
|
17
|
+
self.code = code
|
|
18
|
+
self.body = body
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SolariCompatUnsupported(BrowserbaseError):
|
|
22
|
+
"""A Browserbase feature Solari Browser does not offer (see COMPAT.md)."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, feature: str, hint: Optional[str] = None) -> None:
|
|
25
|
+
super().__init__(f"Not supported by Solari Browser: {feature}" + (f" — {hint}" if hint else ""))
|
|
26
|
+
self.feature = feature
|
|
27
|
+
self.hint = hint
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Browserbase ``sessions.create`` params -> Solari ``POST /sessions`` body. Pure functions, no I/O."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from .errors import SolariCompatUnsupported
|
|
8
|
+
|
|
9
|
+
_REGIONS = {"us-west-2", "us-west"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class MappedCreate:
|
|
14
|
+
body: Dict[str, Any]
|
|
15
|
+
persist_profile_id: Optional[str] = None
|
|
16
|
+
warnings: List[str] = field(default_factory=list)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _proxy_body(proxies: Any, ca_certs: Any) -> Any:
|
|
20
|
+
if ca_certs:
|
|
21
|
+
raise SolariCompatUnsupported("proxy_settings.caCertificates", "custom CA certificates are not supported")
|
|
22
|
+
if proxies is None or proxies is False:
|
|
23
|
+
return None
|
|
24
|
+
if proxies is True:
|
|
25
|
+
return {"country": "us"}
|
|
26
|
+
if not isinstance(proxies, (list, tuple)) or len(proxies) == 0:
|
|
27
|
+
return None
|
|
28
|
+
if len(proxies) > 1:
|
|
29
|
+
raise SolariCompatUnsupported(
|
|
30
|
+
"multiple proxies / domain routing rules",
|
|
31
|
+
"pass at most one managed proxy; per-domain routing is not supported",
|
|
32
|
+
)
|
|
33
|
+
p = proxies[0]
|
|
34
|
+
if p.get("domainPattern"):
|
|
35
|
+
raise SolariCompatUnsupported("proxies[].domainPattern", "per-domain proxy routing is not supported")
|
|
36
|
+
t = p.get("type")
|
|
37
|
+
if t == "external":
|
|
38
|
+
raise SolariCompatUnsupported(
|
|
39
|
+
"external proxy", "bring-your-own proxy servers are not supported; use a managed proxy"
|
|
40
|
+
)
|
|
41
|
+
if t == "none":
|
|
42
|
+
return None # no proxy, same as not asking for one
|
|
43
|
+
if t != "browserbase":
|
|
44
|
+
raise SolariCompatUnsupported(f"proxy type {t!r}")
|
|
45
|
+
g = p.get("geolocation")
|
|
46
|
+
if not g:
|
|
47
|
+
return {"country": "us"}
|
|
48
|
+
out: Dict[str, Any] = {"country": str(g["country"]).lower()}
|
|
49
|
+
if g.get("state"):
|
|
50
|
+
out["state"] = str(g["state"]).lower()
|
|
51
|
+
if g.get("city"):
|
|
52
|
+
out["city"] = g["city"]
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def map_create_params(
|
|
57
|
+
*,
|
|
58
|
+
extension_id: Optional[str] = None,
|
|
59
|
+
browser_settings: Optional[Dict[str, Any]] = None,
|
|
60
|
+
timeout: Optional[int] = None,
|
|
61
|
+
keep_alive: Optional[bool] = None,
|
|
62
|
+
proxies: Any = None,
|
|
63
|
+
proxy_settings: Optional[Dict[str, Any]] = None,
|
|
64
|
+
region: Optional[str] = None,
|
|
65
|
+
user_metadata: Optional[Dict[str, Any]] = None,
|
|
66
|
+
strict: bool = False,
|
|
67
|
+
) -> MappedCreate:
|
|
68
|
+
bs = browser_settings or {}
|
|
69
|
+
warnings: List[str] = []
|
|
70
|
+
|
|
71
|
+
def soft(feature: str, why: str) -> None:
|
|
72
|
+
if strict:
|
|
73
|
+
raise SolariCompatUnsupported(feature, why)
|
|
74
|
+
warnings.append(f"{feature}: {why}")
|
|
75
|
+
|
|
76
|
+
# hard gaps
|
|
77
|
+
if extension_id or bs.get("extensionId"):
|
|
78
|
+
raise SolariCompatUnsupported("extensions", "browser extensions cannot be loaded")
|
|
79
|
+
if bs.get("ignoreCertificateErrors") is True:
|
|
80
|
+
raise SolariCompatUnsupported("browserSettings.ignoreCertificateErrors")
|
|
81
|
+
if bs.get("captchaImageSelector") or bs.get("captchaInputSelector"):
|
|
82
|
+
raise SolariCompatUnsupported("custom CAPTCHA selectors")
|
|
83
|
+
if bs.get("os") in ("mobile", "tablet"):
|
|
84
|
+
raise SolariCompatUnsupported(f"browserSettings.os={bs['os']}", "only desktop browsers")
|
|
85
|
+
if region is not None and region not in _REGIONS:
|
|
86
|
+
raise SolariCompatUnsupported(f"region {region}", "Solari Browser runs in one region (us-west)")
|
|
87
|
+
|
|
88
|
+
# soft gaps
|
|
89
|
+
if timeout is not None:
|
|
90
|
+
soft("timeout", "sessions end at your plan's maximum session length, not at this value; call sessions.update to release earlier")
|
|
91
|
+
if keep_alive is True:
|
|
92
|
+
soft("keep_alive", "a disconnected session is released about 45 s after the socket closes; reconnect inside that window or hold the connection")
|
|
93
|
+
if user_metadata is not None:
|
|
94
|
+
soft("user_metadata", "metadata is not stored or queryable")
|
|
95
|
+
if bs.get("viewport") is not None:
|
|
96
|
+
soft("browserSettings.viewport", "set the viewport from your automation client (page.set_viewport_size)")
|
|
97
|
+
if bs.get("blockAds") is True:
|
|
98
|
+
soft("browserSettings.blockAds", "ad blocking is not available")
|
|
99
|
+
if bs.get("logSession") is True:
|
|
100
|
+
soft("browserSettings.logSession", "session command logs are not available")
|
|
101
|
+
if bs.get("os") in ("windows", "mac", "linux"):
|
|
102
|
+
soft("browserSettings.os", "the operating system profile cannot be chosen")
|
|
103
|
+
|
|
104
|
+
proxy = _proxy_body(proxies, (proxy_settings or {}).get("caCertificates"))
|
|
105
|
+
allowed = bs.get("allowedDomains") or None
|
|
106
|
+
captcha = bs.get("solveCaptchas") is True
|
|
107
|
+
stealth = bool(bs.get("verified") is True or bs.get("advancedStealth") is True or captcha or proxy is not None or allowed)
|
|
108
|
+
|
|
109
|
+
body: Dict[str, Any] = {}
|
|
110
|
+
if stealth:
|
|
111
|
+
body["stealth"] = True
|
|
112
|
+
if captcha:
|
|
113
|
+
body["captcha"] = True
|
|
114
|
+
if bs.get("recordSession") is True:
|
|
115
|
+
body["recording"] = True
|
|
116
|
+
if proxy is not None:
|
|
117
|
+
body["proxy"] = proxy
|
|
118
|
+
if allowed:
|
|
119
|
+
body["allowedDomains"] = list(allowed)
|
|
120
|
+
ctx = bs.get("context") or {}
|
|
121
|
+
if ctx.get("id"):
|
|
122
|
+
body["profileId"] = ctx["id"]
|
|
123
|
+
persist = ctx["id"] if ctx.get("id") and ctx.get("persist") is True else None
|
|
124
|
+
return MappedCreate(body=body, persist_profile_id=persist, warnings=warnings)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def map_status(s: Optional[str]) -> str:
|
|
128
|
+
return {"active": "RUNNING", "released": "COMPLETED", "expired": "TIMED_OUT"}.get(s or "", "ERROR")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Live check against a real gateway (NOT collected by pytest). Creates its own sessions and releases them.
|
|
2
|
+
SOLARI_API_KEY=slr_... SOLARI_BASE_URL=https://api-sta.getsolari.com python tests/live_staging.py
|
|
3
|
+
"""
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from solari_browserbase import APIError, Browserbase, SolariCompatUnsupported
|
|
7
|
+
|
|
8
|
+
bb = Browserbase()
|
|
9
|
+
made = []
|
|
10
|
+
try:
|
|
11
|
+
s = bb.sessions.create(browser_settings={"recordSession": True})
|
|
12
|
+
made.append(s.id)
|
|
13
|
+
print("create:", s.status, s.connect_url.split(":")[0])
|
|
14
|
+
assert s.status == "RUNNING" and s.connect_url.startswith("wss://")
|
|
15
|
+
assert bb.sessions.retrieve(s.id).status == "RUNNING"
|
|
16
|
+
assert any(x.id == s.id for x in bb.sessions.list(status="RUNNING"))
|
|
17
|
+
d = bb.sessions.debug(s.id, expires_in=300)
|
|
18
|
+
print("live view:", d.debugger_url.split("#")[0] + "#<redacted>")
|
|
19
|
+
assert d.debugger_url.startswith("https://")
|
|
20
|
+
assert bb.sessions.update(s.id, status="REQUEST_RELEASE").status == "COMPLETED"
|
|
21
|
+
made.remove(s.id)
|
|
22
|
+
|
|
23
|
+
# managed proxy + stealth mapping is accepted by the gateway
|
|
24
|
+
p = bb.sessions.create(proxies=[{"type": "browserbase", "geolocation": {"country": "US"}}])
|
|
25
|
+
made.append(p.id)
|
|
26
|
+
print("proxy session:", p.status)
|
|
27
|
+
assert p.status == "RUNNING"
|
|
28
|
+
bb.sessions.update(p.id, status="REQUEST_RELEASE")
|
|
29
|
+
made.remove(p.id)
|
|
30
|
+
|
|
31
|
+
c = bb.contexts.create(name=f"bbcompat-py-{int(time.time())}")
|
|
32
|
+
print("context:", bool(c.id), bb.contexts.retrieve(c.id).id == c.id)
|
|
33
|
+
cs = bb.sessions.create(browser_settings={"context": {"id": c.id, "persist": True}})
|
|
34
|
+
made.append(cs.id)
|
|
35
|
+
print("context session storage_state:", cs.storage_state)
|
|
36
|
+
bb.sessions.update(cs.id, status="REQUEST_RELEASE")
|
|
37
|
+
made.remove(cs.id)
|
|
38
|
+
bb.contexts.delete(c.id)
|
|
39
|
+
|
|
40
|
+
for fn in (lambda: bb.sessions.create(extension_id="e"), lambda: bb.extensions.create()):
|
|
41
|
+
try:
|
|
42
|
+
fn()
|
|
43
|
+
raise SystemExit("expected SolariCompatUnsupported")
|
|
44
|
+
except SolariCompatUnsupported:
|
|
45
|
+
pass
|
|
46
|
+
print("OK")
|
|
47
|
+
finally:
|
|
48
|
+
for sid in made:
|
|
49
|
+
try:
|
|
50
|
+
bb.sessions.update(sid, status="REQUEST_RELEASE")
|
|
51
|
+
except APIError:
|
|
52
|
+
pass
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import gzip
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import solari_browserbase as sb
|
|
10
|
+
from solari_browserbase import APIError, AsyncBrowserbase, Browserbase, SolariCompatUnsupported, map_create_params
|
|
11
|
+
|
|
12
|
+
KEY = "slr_live_test_key"
|
|
13
|
+
SESSION = {"sessionId": "sess.abc", "wsEndpoint": "wss://gw/ws/sess.abc", "cdpEndpoint": "wss://gw/cdp/sess.abc", "expiresAt": "2027-01-01T00:00:00.000Z"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Gateway:
|
|
17
|
+
"""Scripted fake gateway behind httpx.MockTransport; records every request."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, routes):
|
|
20
|
+
self.routes = routes
|
|
21
|
+
self.calls = []
|
|
22
|
+
|
|
23
|
+
def __call__(self, request: httpx.Request) -> httpx.Response:
|
|
24
|
+
body = json.loads(request.content) if request.content else None
|
|
25
|
+
self.calls.append({"method": request.method, "path": request.url.path, "body": body, "headers": dict(request.headers)})
|
|
26
|
+
for method, path, reply in self.routes:
|
|
27
|
+
if method == request.method and path == request.url.path:
|
|
28
|
+
out = reply(len(self.calls)) if callable(reply) else reply
|
|
29
|
+
status, payload = out
|
|
30
|
+
if status == 204:
|
|
31
|
+
return httpx.Response(204)
|
|
32
|
+
return httpx.Response(status, json=payload)
|
|
33
|
+
return httpx.Response(500, json={"error": f"no route {request.method} {request.url.path}"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def client(routes, **kw):
|
|
37
|
+
gw = Gateway(routes)
|
|
38
|
+
warnings = []
|
|
39
|
+
bb = Browserbase(api_key=KEY, base_url="https://gw.example", transport=httpx.MockTransport(gw), on_warning=warnings.append, **kw)
|
|
40
|
+
return bb, gw, warnings
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
CREATE = ("POST", "/sessions", (201, SESSION))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_api_key_only_slr_keys_and_never_echoed(monkeypatch):
|
|
47
|
+
warnings = []
|
|
48
|
+
monkeypatch.setenv("SOLARI_API_KEY", "")
|
|
49
|
+
monkeypatch.setenv("BROWSERBASE_API_KEY", "bb_live_SECRETSECRET")
|
|
50
|
+
with pytest.raises(sb.BrowserbaseError):
|
|
51
|
+
Browserbase(on_warning=warnings.append)
|
|
52
|
+
assert len(warnings) == 1 and "SECRETSECRET" not in warnings[0]
|
|
53
|
+
monkeypatch.setenv("SOLARI_API_KEY", "slr_from_env")
|
|
54
|
+
assert Browserbase(api_key="bb_wrong", on_warning=lambda m: None).api_key == "slr_from_env"
|
|
55
|
+
assert Browserbase(api_key=KEY, on_warning=lambda m: None).api_key == KEY
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_create_plain_returns_connect_url_is_cdp_endpoint():
|
|
59
|
+
bb, gw, _ = client([CREATE])
|
|
60
|
+
s = bb.sessions.create()
|
|
61
|
+
assert gw.calls[0]["path"] == "/sessions" and gw.calls[0]["body"] is None
|
|
62
|
+
assert gw.calls[0]["headers"]["idempotency-key"]
|
|
63
|
+
assert gw.calls[0]["headers"]["authorization"] == f"Bearer {KEY}"
|
|
64
|
+
assert s.id == "sess.abc" and s.status == "RUNNING"
|
|
65
|
+
assert s.connect_url == "wss://gw/cdp/sess.abc" # CDP endpoint, never the patchright wire endpoint
|
|
66
|
+
assert s.selenium_remote_url == "" and s.keep_alive is False
|
|
67
|
+
assert s.expires_at == SESSION["expiresAt"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_context_session_returns_saved_storage_state(monkeypatch):
|
|
71
|
+
state = {"cookies": [{"name": "k", "value": "v"}], "origins": []}
|
|
72
|
+
created = {**SESSION, "storageStateUrl": {"url": "https://store.example/state.json", "expiresInSeconds": 60}}
|
|
73
|
+
bb, gw, warnings = client([("POST", "/sessions", (201, created))])
|
|
74
|
+
bb._http.client = httpx.Client(transport=httpx.MockTransport(lambda r: httpx.Response(200, json=state) if r.url.host == "store.example" else gw(r)))
|
|
75
|
+
s = bb.sessions.create(browser_settings={"context": {"id": "prof_1"}})
|
|
76
|
+
assert s.storage_state == state and s.context_id == "prof_1"
|
|
77
|
+
assert gw.calls[0]["body"] == {"profileId": "prof_1"}
|
|
78
|
+
plain, _, _ = client([CREATE])
|
|
79
|
+
assert plain.sessions.create().storage_state is None
|
|
80
|
+
bad, bgw, bwarn = client([("POST", "/sessions", (201, created))])
|
|
81
|
+
bad._http.client = httpx.Client(transport=httpx.MockTransport(lambda r: httpx.Response(500) if r.url.host == "store.example" else bgw(r)))
|
|
82
|
+
assert bad.sessions.create(browser_settings={"context": {"id": "p"}}).storage_state is None and bwarn
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_mapping_soft_and_default_behaviour():
|
|
86
|
+
m = lambda **k: map_create_params(**k).body # noqa: E731
|
|
87
|
+
assert m(proxies=True) == {"stealth": True, "proxy": {"country": "us"}}
|
|
88
|
+
assert m(proxies=[{"type": "browserbase", "geolocation": {"country": "GB", "city": "London"}}]) == {"stealth": True, "proxy": {"country": "gb", "city": "London"}}
|
|
89
|
+
assert m(proxies=[{"type": "browserbase", "geolocation": {"country": "US", "state": "NY"}}])["proxy"] == {"country": "us", "state": "ny"}
|
|
90
|
+
assert m(proxies=[{"type": "none"}]) == {}
|
|
91
|
+
assert m(browser_settings={"verified": True}) == {"stealth": True}
|
|
92
|
+
assert m(browser_settings={"advancedStealth": True}) == {"stealth": True}
|
|
93
|
+
assert m(browser_settings={"solveCaptchas": True}) == {"stealth": True, "captcha": True}
|
|
94
|
+
assert m(browser_settings={"solveCaptchas": False}) == {}, "captcha is opt-in"
|
|
95
|
+
assert m(browser_settings={"recordSession": True}) == {"recording": True}
|
|
96
|
+
assert m(browser_settings={"recordSession": False}) == {}
|
|
97
|
+
assert m(browser_settings={"allowedDomains": ["a.com"]}) == {"stealth": True, "allowedDomains": ["a.com"]}
|
|
98
|
+
c = map_create_params(browser_settings={"context": {"id": "prof_1", "persist": True}})
|
|
99
|
+
assert c.body == {"profileId": "prof_1"} and c.persist_profile_id == "prof_1"
|
|
100
|
+
assert map_create_params(browser_settings={"context": {"id": "prof_1"}}).persist_profile_id is None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.parametrize("kw", [
|
|
104
|
+
{"extension_id": "e1"},
|
|
105
|
+
{"browser_settings": {"extensionId": "e1"}},
|
|
106
|
+
{"proxies": [{"type": "external", "server": "http://p:1"}]},
|
|
107
|
+
{"proxies": [{"type": "browserbase", "domainPattern": "*.x.com"}]},
|
|
108
|
+
{"proxies": [{"type": "browserbase"}, {"type": "none"}]},
|
|
109
|
+
{"proxy_settings": {"caCertificates": ["id"]}},
|
|
110
|
+
{"browser_settings": {"ignoreCertificateErrors": True}},
|
|
111
|
+
{"browser_settings": {"captchaImageSelector": "#a"}},
|
|
112
|
+
{"browser_settings": {"captchaInputSelector": "#b"}},
|
|
113
|
+
{"browser_settings": {"os": "mobile"}},
|
|
114
|
+
{"browser_settings": {"os": "tablet"}},
|
|
115
|
+
{"region": "eu-central-1"},
|
|
116
|
+
])
|
|
117
|
+
def test_hard_gaps_raise(kw):
|
|
118
|
+
with pytest.raises(SolariCompatUnsupported):
|
|
119
|
+
map_create_params(**kw)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_one_region_accepted_even_strict():
|
|
123
|
+
map_create_params(region="us-west-2", strict=True)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.parametrize("kw", [
|
|
127
|
+
{"timeout": 600}, {"keep_alive": True}, {"user_metadata": {"a": 1}},
|
|
128
|
+
{"browser_settings": {"viewport": {"width": 1, "height": 1}}},
|
|
129
|
+
{"browser_settings": {"blockAds": True}}, {"browser_settings": {"os": "windows"}},
|
|
130
|
+
])
|
|
131
|
+
def test_soft_gaps_warn_and_strict_raises(kw):
|
|
132
|
+
assert len(map_create_params(**kw).warnings) == 1
|
|
133
|
+
with pytest.raises(SolariCompatUnsupported):
|
|
134
|
+
map_create_params(strict=True, **kw)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_warnings_emitted_and_strict_sends_nothing():
|
|
138
|
+
bb, gw, warnings = client([CREATE])
|
|
139
|
+
bb.sessions.create(timeout=300, keep_alive=True)
|
|
140
|
+
assert len(warnings) == 2
|
|
141
|
+
sbb, sgw, _ = client([CREATE], )
|
|
142
|
+
sbb._strict = True
|
|
143
|
+
with pytest.raises(SolariCompatUnsupported):
|
|
144
|
+
sbb.sessions.create(timeout=300)
|
|
145
|
+
assert sgw.calls == []
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_retrieve_and_list_map_states():
|
|
149
|
+
rows = [
|
|
150
|
+
{"id": "a", "status": "active", "createdAt": "2026-01-01T00:00:00Z", "expiresAt": "2027-01-01T00:00:00Z"},
|
|
151
|
+
{"id": "b", "status": "released"}, {"id": "c", "status": "expired"}, {"id": "d", "status": "unknown"},
|
|
152
|
+
]
|
|
153
|
+
bb, _, _ = client([
|
|
154
|
+
("GET", "/sessions", (200, {"sessions": rows})),
|
|
155
|
+
("GET", "/sessions/a", (200, {**rows[0], "cdpEndpoint": "wss://gw/cdp/a"})),
|
|
156
|
+
])
|
|
157
|
+
assert [s.status for s in bb.sessions.list()] == ["RUNNING", "COMPLETED", "TIMED_OUT", "ERROR"]
|
|
158
|
+
assert [s.id for s in bb.sessions.list(status="COMPLETED")] == ["b"]
|
|
159
|
+
with pytest.raises(SolariCompatUnsupported):
|
|
160
|
+
bb.sessions.list(q="user_metadata['k']:'v'")
|
|
161
|
+
one = bb.sessions.retrieve("a")
|
|
162
|
+
assert one.status == "RUNNING" and one.connect_url == "wss://gw/cdp/a"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_update_release_saves_profile_first_only_when_persist():
|
|
166
|
+
bb, gw, _ = client([CREATE, ("POST", "/sessions/sess.abc/save-profile", (200, {"ok": True})), ("DELETE", "/sessions/sess.abc", (204, None))])
|
|
167
|
+
bb.sessions.create(browser_settings={"context": {"id": "prof_9", "persist": True}})
|
|
168
|
+
out = bb.sessions.update("sess.abc", status="REQUEST_RELEASE")
|
|
169
|
+
assert out.status == "COMPLETED"
|
|
170
|
+
assert [f"{c['method']} {c['path']}" for c in gw.calls] == ["POST /sessions", "POST /sessions/sess.abc/save-profile", "DELETE /sessions/sess.abc"]
|
|
171
|
+
assert gw.calls[1]["body"] == {"profileId": "prof_9"}
|
|
172
|
+
|
|
173
|
+
bb2, gw2, _ = client([CREATE, ("DELETE", "/sessions/sess.abc", (204, None))])
|
|
174
|
+
bb2.sessions.create(browser_settings={"context": {"id": "prof_9"}})
|
|
175
|
+
bb2.sessions.update("sess.abc", status="REQUEST_RELEASE")
|
|
176
|
+
assert [c["method"] for c in gw2.calls] == ["POST", "DELETE"]
|
|
177
|
+
with pytest.raises(SolariCompatUnsupported):
|
|
178
|
+
bb2.sessions.update("x", status="NOPE")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_debug_returns_share_link():
|
|
182
|
+
bb, gw, _ = client([
|
|
183
|
+
("POST", "/sessions/s1/shares", (201, {"shareId": "vs_1", "url": "https://view.example/v#x"})),
|
|
184
|
+
("GET", "/sessions/s1", (200, {"id": "s1", "status": "active", "cdpEndpoint": "wss://gw/cdp/s1"})),
|
|
185
|
+
])
|
|
186
|
+
d = bb.sessions.debug("s1", expires_in=600)
|
|
187
|
+
assert d.debugger_url == d.debugger_fullscreen_url == "https://view.example/v#x"
|
|
188
|
+
assert d.ws_url == "wss://gw/cdp/s1" and d.pages == []
|
|
189
|
+
assert gw.calls[0]["body"] == {"ttlSeconds": 600}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_recording_retrieve_parses_events(monkeypatch):
|
|
193
|
+
events = [{"type": 4, "data": {"href": "x"}}, {"type": 2, "data": {}}]
|
|
194
|
+
nd = ("\n".join(json.dumps(e) for e in events) + "\n").encode()
|
|
195
|
+
for payload in (gzip.compress(nd), nd):
|
|
196
|
+
monkeypatch.setattr(httpx, "get", lambda url, timeout=0, _p=payload: httpx.Response(200, content=_p, request=httpx.Request("GET", url)))
|
|
197
|
+
bb, _, _ = client([("GET", "/sessions/s1/replay-url", (200, {"url": "https://store.example/r", "expiresInSeconds": 60}))])
|
|
198
|
+
assert bb.sessions.recording.retrieve("s1") == events
|
|
199
|
+
bb, _, _ = client([("GET", "/sessions/s1/replay-url", (404, {"error": "Replay upload is still in flight", "code": "ReplayPending"}))])
|
|
200
|
+
with pytest.raises(APIError) as e:
|
|
201
|
+
bb.sessions.recording.retrieve("s1")
|
|
202
|
+
assert e.value.code == "ReplayPending" and e.value.status == 404
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_contexts_are_profiles():
|
|
206
|
+
bb, gw, _ = client([
|
|
207
|
+
("POST", "/profiles", (201, {"id": "prof_1", "name": "n"})),
|
|
208
|
+
("GET", "/profiles", (200, [{"id": "prof_1", "name": "n", "createdAt": "t"}])),
|
|
209
|
+
("DELETE", "/profiles/prof_1", (204, None)),
|
|
210
|
+
])
|
|
211
|
+
c = bb.contexts.create(name="n")
|
|
212
|
+
assert c.id == "prof_1" and c.public_key == "" and gw.calls[0]["body"] == {"name": "n"}
|
|
213
|
+
assert bb.contexts.retrieve("prof_1").name == "n"
|
|
214
|
+
with pytest.raises(APIError) as e:
|
|
215
|
+
bb.contexts.retrieve("nope")
|
|
216
|
+
assert e.value.status == 404
|
|
217
|
+
bb.contexts.delete("prof_1")
|
|
218
|
+
with pytest.raises(SolariCompatUnsupported):
|
|
219
|
+
bb.contexts.update()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_unsupported_resources_raise():
|
|
223
|
+
bb, _, _ = client([])
|
|
224
|
+
for fn in (lambda: bb.extensions.create(), lambda: bb.projects.list(), lambda: bb.projects.usage("x"),
|
|
225
|
+
lambda: bb.agents.create(), lambda: bb.fetch_api.create(), lambda: bb.search.web(),
|
|
226
|
+
lambda: bb.sessions.replays.retrieve("s"), lambda: bb.sessions.logs.list("s"),
|
|
227
|
+
lambda: bb.sessions.downloads.list("s"), lambda: bb.sessions.uploads.create("s")):
|
|
228
|
+
with pytest.raises(SolariCompatUnsupported):
|
|
229
|
+
fn()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_errors_and_idempotent_create_retry(monkeypatch):
|
|
233
|
+
monkeypatch.setattr("time.sleep", lambda s: None)
|
|
234
|
+
bb, gw, _ = client([("POST", "/sessions", lambda n: (503, {"error": "busy"}) if n < 2 else (201, SESSION))])
|
|
235
|
+
assert bb.sessions.create().id == "sess.abc"
|
|
236
|
+
assert len(gw.calls) == 2 and gw.calls[0]["headers"]["idempotency-key"] == gw.calls[1]["headers"]["idempotency-key"]
|
|
237
|
+
f, _, _ = client([("POST", "/sessions", (403, {"error": "Egress allowlisting requires an enterprise plan", "code": "FeatureRequiresPlan"}))])
|
|
238
|
+
with pytest.raises(APIError) as e:
|
|
239
|
+
f.sessions.create(browser_settings={"allowedDomains": ["a.com"]})
|
|
240
|
+
assert e.value.status == 403 and e.value.code == "FeatureRequiresPlan"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_async_client_runs_calls():
|
|
244
|
+
gw = Gateway([CREATE, ("DELETE", "/sessions/sess.abc", (204, None))])
|
|
245
|
+
|
|
246
|
+
async def main():
|
|
247
|
+
bb = AsyncBrowserbase(api_key=KEY, base_url="https://gw.example", transport=httpx.MockTransport(gw), on_warning=lambda m: None)
|
|
248
|
+
s = await bb.sessions.create()
|
|
249
|
+
assert s.connect_url == "wss://gw/cdp/sess.abc"
|
|
250
|
+
done = await bb.sessions.update(s.id, status="REQUEST_RELEASE")
|
|
251
|
+
assert done.status == "COMPLETED"
|
|
252
|
+
with pytest.raises(SolariCompatUnsupported):
|
|
253
|
+
bb.extensions.create()
|
|
254
|
+
await bb.close()
|
|
255
|
+
|
|
256
|
+
asyncio.run(main())
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_client_never_prints_its_key():
|
|
260
|
+
bb, _, _ = client([])
|
|
261
|
+
assert KEY not in repr(bb) and KEY not in str(vars(bb.sessions)) and KEY not in repr(AsyncBrowserbase(api_key=KEY, on_warning=lambda m: None))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_user_facing_refusal_messages_are_pinned():
|
|
265
|
+
def msg(**kw):
|
|
266
|
+
try:
|
|
267
|
+
map_create_params(**kw)
|
|
268
|
+
except SolariCompatUnsupported as e:
|
|
269
|
+
return str(e)
|
|
270
|
+
assert msg(extension_id="e") == "Not supported by Solari Browser: extensions — browser extensions cannot be loaded"
|
|
271
|
+
assert msg(proxies=[{"type": "external", "server": "s"}]) == "Not supported by Solari Browser: external proxy — bring-your-own proxy servers are not supported; use a managed proxy"
|
|
272
|
+
assert msg(region="eu-central-1") == "Not supported by Solari Browser: region eu-central-1 — Solari Browser runs in one region (us-west)"
|
|
273
|
+
assert msg(browser_settings={"os": "mobile"}) == "Not supported by Solari Browser: browserSettings.os=mobile — only desktop browsers"
|