refactor: cleanup

This commit is contained in:
zhom
2026-09-09 10:09:14 +04:00
parent 598d3bd513
commit dd42d46753
249 changed files with 67417 additions and 6659 deletions
+37
View File
@@ -0,0 +1,37 @@
# donutbrowser
A thin Python client for the [Donut Browser](https://donutbrowser.com) local
REST API. Every method wraps exactly one documented endpoint; nothing is
invented, cached or retried.
The local API is off by default. Switch it on in the app under **Settings →
Integrations → Local API → "Enable Local API Server"**, then copy the port and
the authentication token from that screen.
```bash
pip install -e . # from this directory
```
```python
from donutbrowser import DonutClient
with DonutClient(token="...") as client:
with client.run(profile_id, url="https://example.com", headless=True) as session:
print(session.cdp_url)
```
The client reads `DONUT_API_TOKEN` and `DONUT_API_PORT` when the token and port
are not passed as arguments.
Full documentation, including the Node package and a worked agent example, is in
[`sdk/README.md`](../README.md).
## Tests
```bash
pip install -e ".[dev]"
pytest
```
The suite runs entirely against a fake HTTP server on loopback. It never reaches
the network and never needs the desktop app.
+40
View File
@@ -0,0 +1,40 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "donutbrowser"
version = "0.1.0"
description = "Thin client for the Donut Browser local REST API"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "AGPL-3.0" }
keywords = ["donut-browser", "browser-automation", "anti-detect", "cdp"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Internet :: WWW/HTTP",
"Typing :: Typed",
]
# No runtime dependencies on purpose: this client talks to a loopback server on
# the same machine, so the standard library is enough and installing the SDK can
# never drag a transitive dependency into an automation environment.
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=7"]
[project.urls]
Homepage = "https://donutbrowser.com"
Source = "https://github.com/zhom/donutbrowser"
[tool.hatch.build.targets.wheel]
packages = ["src/donutbrowser"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+59
View File
@@ -0,0 +1,59 @@
"""Donut Browser SDK: a thin client for the app's local REST API.
The local API is off by default. Switch it on in the app under **Settings,
Integrations, Local API, "Enable Local API Server"**, and copy the port and the
authentication token from that screen.
::
from donutbrowser import DonutClient
with DonutClient(token="...") as client:
with client.run(profile_id, url="https://example.com") as session:
client.agent_click(profile_id, locator={"role": "button", "name": "Sign in"})
"""
from .client import DEFAULT_HOST, DEFAULT_PORT, DonutClient, RunSession
from .coverage import OMITTED, OPERATIONS
from .errors import (
BadGateway,
Conflict,
DonutAPIError,
DonutConnectionError,
DonutError,
Forbidden,
NotFound,
PaymentRequired,
RateLimited,
RequestTimeout,
ServerError,
ServiceUnavailable,
Unauthorized,
ValidationError,
)
__version__ = "0.1.0"
__all__ = [
"DonutClient",
"RunSession",
"DEFAULT_HOST",
"DEFAULT_PORT",
"OPERATIONS",
"OMITTED",
"DonutError",
"DonutConnectionError",
"DonutAPIError",
"ValidationError",
"Unauthorized",
"PaymentRequired",
"Forbidden",
"NotFound",
"RequestTimeout",
"Conflict",
"RateLimited",
"ServerError",
"BadGateway",
"ServiceUnavailable",
"__version__",
]
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
"""Which app operation each client method wraps.
This table is the SDK's half of a two-sided check. ``sdk/api-paths.json`` holds
every operation the desktop app publishes, generated from
``src-tauri/src/api_server.rs``. The test suite asserts the two agree exactly in
both directions, so:
* an endpoint added to the app fails the SDK tests until it is wrapped here, or
listed in :data:`OMITTED` with a reason, and
* an entry here that the app no longer publishes fails too.
The same table is mirrored in the Node package, and the same snapshot proves it.
"""
from __future__ import annotations
from typing import Dict, Tuple
__all__ = ["OPERATIONS", "OMITTED"]
Operation = Tuple[str, str]
#: ``(method, path template)`` to the name of the :class:`~donutbrowser.DonutClient`
#: method that calls it.
OPERATIONS: Dict[Operation, str] = {
("POST", "/v1/browsers/download"): "download_browser",
("GET", "/v1/browsers/{browser}/versions"): "list_browser_versions",
("GET", "/v1/browsers/{browser}/versions/{version}/downloaded"): "is_browser_downloaded",
("GET", "/v1/cookie-bot/conflicts"): "get_cookie_bot_conflicts",
("GET", "/v1/cookie-bot/presets"): "list_cookie_bot_presets",
("GET", "/v1/cookie-bot/runs"): "list_cookie_bot_runs",
("POST", "/v1/cookie-bot/runs"): "start_cookie_bot_run",
("DELETE", "/v1/cookie-bot/runs/{run_id}"): "cancel_cookie_bot_run",
("GET", "/v1/cookie-bot/schedules"): "list_cookie_bot_schedules",
("DELETE", "/v1/cookie-bot/schedules/{profile_id}"): "delete_cookie_bot_schedule",
("GET", "/v1/cookie-bot/schedules/{profile_id}"): "get_cookie_bot_schedule",
("PUT", "/v1/cookie-bot/schedules/{profile_id}"): "set_cookie_bot_schedule",
("GET", "/v1/cookie-bot/usage"): "get_cookie_bot_usage",
("GET", "/v1/extension-groups"): "list_extension_groups",
("POST", "/v1/extension-groups"): "create_extension_group",
("DELETE", "/v1/extension-groups/{id}"): "delete_extension_group",
("GET", "/v1/extension-groups/{id}"): "get_extension_group",
("PUT", "/v1/extension-groups/{id}"): "update_extension_group",
(
"DELETE",
"/v1/extension-groups/{id}/extensions/{extension_id}",
): "remove_extension_from_group",
("POST", "/v1/extension-groups/{id}/extensions/{extension_id}"): "add_extension_to_group",
("GET", "/v1/extensions"): "list_extensions",
("POST", "/v1/extensions"): "create_extension",
("DELETE", "/v1/extensions/{id}"): "delete_extension",
("GET", "/v1/extensions/{id}"): "get_extension",
("PUT", "/v1/extensions/{id}"): "update_extension",
("GET", "/v1/groups"): "list_groups",
("POST", "/v1/groups"): "create_group",
("DELETE", "/v1/groups/{id}"): "delete_group",
("GET", "/v1/groups/{id}"): "get_group",
("PUT", "/v1/groups/{id}"): "update_group",
("GET", "/v1/profiles"): "list_profiles",
("POST", "/v1/profiles"): "create_profile",
("POST", "/v1/profiles/batch/run"): "batch_run_profiles",
("POST", "/v1/profiles/batch/stop"): "batch_stop_profiles",
("POST", "/v1/profiles/distribute-proxies"): "distribute_proxies",
("POST", "/v1/profiles/import"): "import_profiles",
("GET", "/v1/profiles/import/detect"): "detect_import_profiles",
("DELETE", "/v1/profiles/{id}"): "delete_profile",
("GET", "/v1/profiles/{id}"): "get_profile",
("PUT", "/v1/profiles/{id}"): "update_profile",
("POST", "/v1/profiles/{id}/agent/click"): "agent_click",
("POST", "/v1/profiles/{id}/agent/extract"): "agent_extract",
("POST", "/v1/profiles/{id}/agent/perceive"): "agent_perceive",
("POST", "/v1/profiles/{id}/agent/pick"): "agent_pick",
("POST", "/v1/profiles/{id}/agent/resolve-locator"): "agent_resolve_locator",
("POST", "/v1/profiles/{id}/agent/type"): "agent_type",
("POST", "/v1/profiles/{id}/cloud-sync"): "set_profile_cloud_sync",
("POST", "/v1/profiles/{id}/cookies/import"): "import_profile_cookies",
("POST", "/v1/profiles/{id}/kill"): "kill_profile",
("POST", "/v1/profiles/{id}/open-url"): "open_url",
("POST", "/v1/profiles/{id}/run"): "run_profile",
("POST", "/v1/profiles/{id}/run-remote"): "run_profile_remote",
("GET", "/v1/proxies"): "list_proxies",
("POST", "/v1/proxies"): "create_proxy",
("POST", "/v1/proxies/import"): "import_proxies",
("DELETE", "/v1/proxies/{id}"): "delete_proxy",
("GET", "/v1/proxies/{id}"): "get_proxy",
("PUT", "/v1/proxies/{id}"): "update_proxy",
("GET", "/v1/remote-hours"): "get_remote_hours",
("GET", "/v1/remote-sessions"): "list_remote_sessions",
("DELETE", "/v1/remote-sessions/{id}"): "stop_remote_session",
("GET", "/v1/remote-sessions/{id}"): "get_remote_session",
("GET", "/v1/tags"): "list_tags",
("GET", "/v1/vpns"): "list_vpns",
("POST", "/v1/vpns"): "create_vpn",
("POST", "/v1/vpns/import"): "import_vpn",
("DELETE", "/v1/vpns/{id}"): "delete_vpn",
("GET", "/v1/vpns/{id}"): "get_vpn",
("PUT", "/v1/vpns/{id}"): "update_vpn",
("GET", "/v1/vpns/{id}/export"): "export_vpn",
}
#: Operations this SDK deliberately does not call, and why.
OMITTED: Dict[Operation, str] = {
(
"GET",
"/v1/remote-sessions/{id}/cdp",
): (
"A WebSocket upgrade, not a request. An HTTP client cannot speak it, and "
"bundling a websocket implementation would end this package's zero-dependency "
"promise for one endpoint. DonutClient.remote_session_cdp_url() builds the "
"ws:// address so a websocket library of the caller's choosing can connect, "
"sending the same Authorization: Bearer header on the handshake."
),
}
+240
View File
@@ -0,0 +1,240 @@
"""Exceptions raised by the Donut Browser SDK.
The local REST API answers with a plain-text body and one of a small set of
statuses. Each status means one thing, so each gets its own exception and a
caller can branch on the class instead of on a number:
=== ========================== ==================================
403 ``Forbidden`` Terms not accepted, or not signed in
400 ``ValidationError`` Malformed request, duplicate name
401 ``Unauthorized`` Missing or wrong bearer token
402 ``PaymentRequired`` Automation needs an active paid plan
404 ``NotFound`` No such profile, group, proxy, ...
408 ``RequestTimeout`` ``agent/pick`` waited and nothing was picked
409 ``Conflict`` Something else holds the profile right now
429 ``RateLimited`` Automation quota spent; see ``retry_after``
500 ``ServerError`` Internal failure
502 ``BadGateway`` The browser or relay answered wrongly
503 ``ServiceUnavailable`` Cloud, fleet or lock service unreachable
=== ========================== ==================================
Some bodies are the structured ``{"code": ..., "params": {...}}`` strings the
desktop app shares with its own frontend. When one arrives, ``code`` and
``params`` are filled in; otherwise ``code`` is ``None`` and ``body`` holds the
diagnostic text as sent.
"""
from __future__ import annotations
import json
from typing import Any, Mapping, Optional
__all__ = [
"DonutError",
"DonutConnectionError",
"DonutAPIError",
"ValidationError",
"Unauthorized",
"PaymentRequired",
"Forbidden",
"NotFound",
"RequestTimeout",
"Conflict",
"RateLimited",
"ServerError",
"BadGateway",
"ServiceUnavailable",
"error_for_status",
]
class DonutError(Exception):
"""Base class for everything this package raises."""
class DonutConnectionError(DonutError):
"""The app could not be reached at all.
Usually means the local API is switched off, is listening on another port,
or the desktop app is not running.
"""
class DonutAPIError(DonutError):
"""The app answered, and the answer was an error status."""
#: HTTP status this class is raised for. ``None`` on the base class, which
#: catches every status without a more specific subclass.
status: Optional[int] = None
def __init__(
self,
status: int,
body: str,
*,
method: str = "",
path: str = "",
headers: Optional[Mapping[str, str]] = None,
) -> None:
self.status = status
self.body = body
self.method = method
self.path = path
self.headers = dict(headers or {})
self.code: Optional[str] = None
self.params: dict[str, Any] = {}
stripped = body.strip()
if stripped.startswith("{"):
try:
decoded = json.loads(stripped)
except ValueError:
decoded = None
if isinstance(decoded, dict) and isinstance(decoded.get("code"), str):
self.code = decoded["code"]
params = decoded.get("params")
if isinstance(params, dict):
self.params = params
where = f"{method} {path}".strip()
detail = self.code or stripped or "(empty body)"
super().__init__(f"{status} on {where}: {detail}" if where else f"{status}: {detail}")
class ValidationError(DonutAPIError):
"""400: the request was malformed, duplicated a name, or named something unsupported."""
status = 400
class Unauthorized(DonutAPIError):
"""401: no bearer token, the wrong one, or the local API has no token stored."""
status = 401
class PaymentRequired(DonutAPIError):
"""402: this action needs an active paid plan, or the proxy behind it lapsed."""
status = 402
class Forbidden(DonutAPIError):
"""403: the Wayfern terms are not accepted, or this desktop is not signed in."""
status = 403
class NotFound(DonutAPIError):
"""404: no entity with that id."""
status = 404
class RequestTimeout(DonutAPIError):
"""408: ``agent/pick`` waited its whole timeout and nothing was picked."""
status = 408
class Conflict(DonutAPIError):
"""409: something else holds the profile — a browser, a teammate, a remote session."""
status = 409
class RateLimited(DonutAPIError):
"""429: the shared automation quota is spent.
``retry_after`` is the number of seconds the server asked the caller to
wait, taken from the ``Retry-After`` response header. It is ``None`` only
when the header is missing or unreadable.
"""
status = 429
def __init__(
self,
status: int,
body: str,
*,
method: str = "",
path: str = "",
headers: Optional[Mapping[str, str]] = None,
) -> None:
super().__init__(status, body, method=method, path=path, headers=headers)
self.retry_after: Optional[int] = None
raw = next(
(value for key, value in self.headers.items() if key.lower() == "retry-after"),
None,
)
if raw is not None:
try:
self.retry_after = int(str(raw).strip())
except ValueError:
self.retry_after = None
class ServerError(DonutAPIError):
"""500 and the other 5xx: the app, the fleet or an upstream failed.
``BadGateway`` and ``ServiceUnavailable`` derive from this, so one
``except ServerError`` catches every server-side failure.
"""
status = 500
class BadGateway(ServerError):
"""502: the browser or the relay did not answer the way it documents."""
status = 502
class ServiceUnavailable(ServerError):
"""503: Donut cloud, the remote fleet, or the profile lock service is unreachable.
Whatever was running keeps running: a 503 from ``kill`` or from stopping a
remote session means the browser is still up, not that it stopped.
"""
status = 503
_BY_STATUS: dict[int, type[DonutAPIError]] = {
cls.status: cls
for cls in (
ValidationError,
Unauthorized,
PaymentRequired,
Forbidden,
NotFound,
RequestTimeout,
Conflict,
RateLimited,
ServerError,
BadGateway,
ServiceUnavailable,
)
if cls.status is not None
}
def error_for_status(
status: int,
body: str,
*,
method: str = "",
path: str = "",
headers: Optional[Mapping[str, str]] = None,
) -> DonutAPIError:
"""Build the exception that belongs to ``status``.
A status with no class of its own becomes a plain :class:`DonutAPIError`,
so a future status added to the app still raises something a caller can
catch rather than escaping as a decode failure.
"""
cls = _BY_STATUS.get(status)
if cls is None:
cls = ServerError if status >= 500 else DonutAPIError
return cls(status, body, method=method, path=path, headers=headers)
+763
View File
@@ -0,0 +1,763 @@
"""Response shapes, spelled exactly the way the local API sends them.
Every entry here mirrors a ``ToSchema`` struct in ``src-tauri`` field for field.
A Rust ``Option<T>`` becomes a key that may be absent, expressed with the
``total=False`` half of each pair of classes, so ``dict.get`` is the honest way
to read one.
Two spellings live side by side because the app sends both. Most bodies are
snake_case; the browser-facing agent types (``LocatorDescription``,
``LocatorCandidate``, ``PerceptionPage`` and friends) carry the browser's own
camelCase, because they are handed through from the browser rather than
restated. ``AgentClick`` and ``AgentTyping`` are the exceptions inside the
agent surface: they are snake_case with a single ``match`` key. The types below
follow the wire rather than tidying it, so a value read from one call can be
passed straight into the next.
"""
from __future__ import annotations
from typing import Any, Dict, List, TypedDict
__all__ = [
"ApiProfile",
"ApiProfilesResponse",
"ApiProfileResponse",
"ApiGroupResponse",
"ApiProxyResponse",
"ApiVpnResponse",
"ApiVpnExportResponse",
"DownloadBrowserResponse",
"RunProfileResponse",
"RunRemoteResponse",
"StopRemoteResponse",
"SetCloudSyncResponse",
"RemoteSessionState",
"ApiRemoteSessionsResponse",
"RemoteHoursBreakdown",
"RemoteHoursMember",
"RemoteHoursQuota",
"CookieBotSlot",
"CookieBotSchedule",
"CookieBotScheduleList",
"CookieBotConflict",
"CookieBotScheduleSaved",
"CookieBotConflictCheck",
"CookieBotScheduleDeleted",
"CookieBotRun",
"CookieBotRunPage",
"CookieBotRunStarted",
"CookieBotPreset",
"CookieBotPresetList",
"CookieBotUsageMember",
"CookieBotUsageProfile",
"CookieBotUsage",
"BatchRunResult",
"BatchRunResponse",
"BatchStopResult",
"BatchStopResponse",
"ProxyPair",
"ProxyAssignmentResult",
"DistributeProxiesResponse",
"ImportCookiesResponse",
"ImportProxiesResponse",
"DetectedProfile",
"DetectedProfilesResponse",
"ImportProfileItem",
"ProfileImportItemResult",
"ProfileImportBatchResult",
"Extension",
"ExtensionGroup",
"LocatorAttribute",
"LocatorDescription",
"LocatorBounds",
"LocatorCandidate",
"LocatorResolution",
"PerceptionNode",
"PerceptionFrame",
"PerceptionStats",
"PerceptionPage",
"ExtractionField",
"ExtractionRow",
"Extraction",
"PickedElement",
"AgentClick",
"AgentTyping",
]
# The app's own JSON for a proxy's settings. Declared `Object` in the OpenAPI
# document rather than a struct, so it is passed through untouched.
ProxySettings = Dict[str, Any]
# A Wayfern fingerprint/config blob. Also declared `Object` in the document.
WayfernConfig = Dict[str, Any]
class _ApiProfileRequired(TypedDict):
id: str
name: str
browser: str
version: str
release_type: str
tags: List[str]
is_running: bool
proxy_bypass_rules: List[str]
ephemeral: bool
temporary: bool
clear_on_close: bool
sync_mode: str
cloud_sync_enabled: bool
is_cross_os: bool
class ApiProfile(_ApiProfileRequired, total=False):
proxy_id: str
launch_hook: str
process_id: int
last_launch: int
group_id: str
vpn_id: str
extension_group_id: str
host_os: str
fingerprint_os: str
class ApiProfilesResponse(TypedDict):
profiles: List[ApiProfile]
total: int
class ApiProfileResponse(TypedDict):
profile: ApiProfile
class ApiGroupResponse(TypedDict):
id: str
name: str
profile_count: int
class ApiProxyResponse(TypedDict):
id: str
name: str
proxy_settings: ProxySettings
class _ApiVpnRequired(TypedDict):
id: str
name: str
vpn_type: str
created_at: int
class ApiVpnResponse(_ApiVpnRequired, total=False):
last_used: int
class ApiVpnExportResponse(TypedDict):
id: str
name: str
vpn_type: str
config_data: str
class DownloadBrowserResponse(TypedDict):
browser: str
version: str
status: str
class RunProfileResponse(TypedDict):
profile_id: str
remote_debugging_port: int
headless: bool
class RunRemoteResponse(TypedDict):
profile_id: str
session_id: str
platform: str
status: str
class StopRemoteResponse(TypedDict):
session_id: str
status: str
billed_seconds: int
class _SetCloudSyncRequired(TypedDict):
profile_id: str
mode: str
remote_launchable: bool
class SetCloudSyncResponse(_SetCloudSyncRequired, total=False):
remote_blocked_reason: str
class _RemoteSessionStateRequired(TypedDict):
session_id: str
state: str
class RemoteSessionState(_RemoteSessionStateRequired, total=False):
profile_id: str
platform: str
cdp_ready: bool
kind: str
run_id: str
team_id: str
started_at: str
ended_at: str
close_reason: str
billed_seconds: int
class ApiRemoteSessionsResponse(TypedDict):
sessions: List[RemoteSessionState]
class RemoteHoursBreakdown(TypedDict, total=False):
interactive_hours: float
bot_hours: float
class _RemoteHoursMemberRequired(TypedDict):
user_id: str
email: str
class RemoteHoursMember(_RemoteHoursMemberRequired, total=False):
role: str
used_hours: float
interactive_hours: float
bot_hours: float
class _RemoteHoursQuotaRequired(TypedDict):
granted_hours: float
remaining_hours: float
class RemoteHoursQuota(_RemoteHoursQuotaRequired, total=False):
used_hours: float
period_start: str
period_end: str
scope: str
team_id: str
seats: int
per_seat_hours: float
breakdown: RemoteHoursBreakdown
members: List[RemoteHoursMember]
class CookieBotSlot(TypedDict, total=False):
run_at_minute: int
days_mask: int
class _CookieBotScheduleRequired(TypedDict):
profile_id: str
profile_name: str
platform: str
enabled: bool
run_at_minute: int
days_mask: int
timezone: str
preset: str
max_minutes: int
class CookieBotSchedule(_CookieBotScheduleRequired, total=False):
slots: List[CookieBotSlot]
template_id: str
sites: List[str]
jitter_seconds: int
sync_enabled: bool
encrypted_sync: bool
has_proxy: bool
proxy_remote_reachable: bool
touch_fingerprint: bool
sticky_exit: bool
profile_state_at: str
blocked_by: str
next_run_at: str
last_run_at: str
last_run_id: str
owner_user_id: str
owner_email: str
updated_at: str
class CookieBotScheduleList(TypedDict, total=False):
schedules: List[CookieBotSchedule]
team_id: str
scope: str
class _CookieBotConflictRequired(TypedDict):
user_id: str
email: str
run_at_minute: int
timezone: str
days_mask: int
enabled: bool
class CookieBotConflict(_CookieBotConflictRequired, total=False):
overlaps: bool
class _CookieBotScheduleSavedRequired(TypedDict):
schedule: CookieBotSchedule
class CookieBotScheduleSaved(_CookieBotScheduleSavedRequired, total=False):
conflicts: List[CookieBotConflict]
class _CookieBotConflictCheckRequired(TypedDict):
profile_id: str
class CookieBotConflictCheck(_CookieBotConflictCheckRequired, total=False):
conflicts: List[CookieBotConflict]
class CookieBotScheduleDeleted(TypedDict):
profile_id: str
deleted: bool
class _CookieBotRunRequired(TypedDict):
id: str
profile_id: str
trigger: str
status: str
scheduled_for: str
class CookieBotRun(_CookieBotRunRequired, total=False):
profile_name: str
user_id: str
email: str
team_id: str
dispatch_after: str
started_at: str
ended_at: str
max_minutes: int
chunks_total: int
chunk_index: int
sites_total: int
sites_visited: int
sites_failed: int
consent_dismissed: int
billed_seconds: int
outcome_code: str
session_id: str
class CookieBotRunPage(TypedDict, total=False):
runs: List[CookieBotRun]
next_before: str
class _CookieBotRunStartedRequired(TypedDict):
run: CookieBotRun
class CookieBotRunStarted(_CookieBotRunStartedRequired, total=False):
session_id: str
class _CookieBotPresetRequired(TypedDict):
id: str
class CookieBotPreset(_CookieBotPresetRequired, total=False):
typical_minutes: int
recommended: bool
name: str
description: str
class CookieBotPresetList(TypedDict, total=False):
presets: List[CookieBotPreset]
default_preset: str
# `templates` and `limits` are whatever the server publishes; the app
# forwards them without narrowing, so neither is spelled out here.
templates: List[Dict[str, Any]]
limits: Dict[str, Any]
class _CookieBotUsageMemberRequired(TypedDict):
user_id: str
email: str
class CookieBotUsageMember(_CookieBotUsageMemberRequired, total=False):
role: str
interactive_hours: float
bot_hours: float
used_hours: float
sessions: int
bot_runs: int
bot_runs_failed: int
class _CookieBotUsageProfileRequired(TypedDict):
profile_id: str
class CookieBotUsageProfile(_CookieBotUsageProfileRequired, total=False):
profile_name: str
owner_email: str
bot_hours: float
runs: int
runs_failed: int
last_run_at: str
last_status: str
class _CookieBotUsageRequired(TypedDict):
period: str
class CookieBotUsage(_CookieBotUsageRequired, total=False):
period_start: str
period_end: str
team_id: str
seats: int
granted_hours: float
used_hours: float
remaining_hours: float
members: List[CookieBotUsageMember]
profiles: List[CookieBotUsageProfile]
class _BatchRunResultRequired(TypedDict):
profile_id: str
ok: bool
class BatchRunResult(_BatchRunResultRequired, total=False):
remote_debugging_port: int
error: str
class BatchRunResponse(TypedDict):
results: List[BatchRunResult]
class _BatchStopResultRequired(TypedDict):
profile_id: str
ok: bool
class BatchStopResult(_BatchStopResultRequired, total=False):
error: str
class BatchStopResponse(TypedDict):
results: List[BatchStopResult]
class _ProxyAssignmentResultRequired(TypedDict):
profile_id: str
proxy_id: str
ok: bool
class ProxyAssignmentResult(_ProxyAssignmentResultRequired, total=False):
"""``error`` is a ``{"code": ...}`` payload when ``ok`` is false."""
error: str
class ProxyPair(TypedDict):
"""One profile, one proxy. The distribution applies exactly these pairs."""
profile_id: str
proxy_id: str
class DistributeProxiesResponse(TypedDict):
results: List[ProxyAssignmentResult]
class ImportCookiesResponse(TypedDict):
cookies_imported: int
cookies_replaced: int
errors: List[str]
class ImportProxiesResponse(TypedDict):
imported_count: int
skipped_count: int
errors: List[str]
proxies: List[ApiProxyResponse]
class DetectedProfile(TypedDict):
browser: str
mapped_browser: str
name: str
path: str
description: str
class DetectedProfilesResponse(TypedDict):
profiles: List[DetectedProfile]
total: int
class _ImportProfileItemRequired(TypedDict):
source_path: str
new_profile_name: str
class ImportProfileItem(_ImportProfileItemRequired, total=False):
"""One item of ``import_profiles``.
``browser_type`` defaults to the app's own default when absent, and it is
load-bearing: it picks which keychain entry unlocks the source's cookies
and passwords.
"""
browser_type: str
proxy_id: str
vpn_id: str
allow_running: bool
class _ProfileImportItemResultRequired(TypedDict):
name: str
source_path: str
status: str
class ProfileImportItemResult(_ProfileImportItemResultRequired, total=False):
profile_id: str
error: str
report: Dict[str, Any]
class ProfileImportBatchResult(TypedDict):
imported_count: int
skipped_count: int
failed_count: int
results: List[ProfileImportItemResult]
class _ExtensionRequired(TypedDict):
id: str
name: str
file_name: str
file_type: str
browser_compatibility: List[str]
created_at: int
updated_at: int
source_kind: str
class Extension(_ExtensionRequired, total=False):
manifest_name: str
sync_enabled: bool
last_sync: int
version: str
description: str
author: str
homepage_url: str
linked_path: str
class _ExtensionGroupRequired(TypedDict):
id: str
name: str
extension_ids: List[str]
created_at: int
updated_at: int
class ExtensionGroup(_ExtensionGroupRequired, total=False):
sync_enabled: bool
last_sync: int
class LocatorAttribute(TypedDict):
name: str
value: str
class LocatorDescription(TypedDict, total=False):
"""How an element is named without a CSS selector.
At least one key must be set. Keys are the browser's own camelCase; the
app also accepts ``name_contains`` and ``text_contains`` on input, but a
locator handed back by ``agent_pick`` uses the spellings below, so reusing
one verbatim is the reliable path.
"""
role: str
name: str
nameContains: str
text: str
textContains: str
attributes: List[LocatorAttribute]
class LocatorBounds(TypedDict):
x: float
y: float
width: float
height: float
class _LocatorCandidateRequired(TypedDict):
role: str
name: str
text: str
signature: str
bounds: LocatorBounds
class LocatorCandidate(_LocatorCandidateRequired, total=False):
backendNodeId: int
value: str
url: str
attributes: List[LocatorAttribute]
class _LocatorResolutionRequired(TypedDict):
matchCount: int
# `match` is the key the app sends. It is a soft keyword in Python, so it
# is spelled here exactly as it arrives.
match: LocatorCandidate
locator: LocatorDescription
engine: str
class LocatorResolution(_LocatorResolutionRequired, total=False):
backendNodeId: int
class _PerceptionNodeRequired(TypedDict):
id: str
frameId: str
role: str
x: float
y: float
width: float
height: float
inViewport: bool
visible: bool
focused: bool
disabled: bool
class PerceptionNode(_PerceptionNodeRequired, total=False):
parentId: str
name: str
text: str
value: str
checked: str
expanded: bool
scrollable: bool
scrollContainerId: str
class _PerceptionFrameRequired(TypedDict):
frameId: str
url: str
crossOrigin: bool
class PerceptionFrame(_PerceptionFrameRequired, total=False):
parentFrameId: str
class PerceptionStats(TypedDict):
totalNodes: int
returnedNodes: int
bytes: int
elapsedMs: int
framesVisited: int
framesFailed: int
class _PerceptionPageRequired(TypedDict):
snapshotId: str
nodes: List[PerceptionNode]
frames: List[PerceptionFrame]
text: str
truncated: bool
stats: PerceptionStats
engine: str
class PerceptionPage(_PerceptionPageRequired, total=False):
cursor: str
class _ExtractionFieldRequired(TypedDict):
key: str
locator: LocatorDescription
source: str
class ExtractionField(_ExtractionFieldRequired, total=False):
"""One output column. ``attribute`` is required when ``source`` is ``"attribute"``."""
attribute: str
class ExtractionRow(TypedDict):
index: int
page: int
values: Dict[str, Any]
class Extraction(TypedDict):
rows: List[ExtractionRow]
rowCount: int
pageCount: int
byteSize: int
truncated: bool
stopReason: str
engine: str
class PickedElement(TypedDict):
backendNodeId: int
locator: LocatorDescription
matchCount: int
node: LocatorCandidate
engine: str
class AgentClick(TypedDict):
"""What a click did. Note the snake_case body and the ``match`` key."""
clicked: bool
match: LocatorCandidate
engine: str
navigated: bool
class _AgentTypingRequired(TypedDict):
typed: bool
characters: int
duration_ms: float
engine: str
match: LocatorCandidate
class AgentTyping(_AgentTypingRequired, total=False):
"""What a typing call did.
``corrections`` is absent on the fallback engine, which does not count its
own mistypes.
"""
corrections: int
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Iterator
import pytest
# Run against the working tree without an install step, so `pytest` works
# straight after a checkout.
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from donutbrowser import DonutClient # noqa: E402
from fake_donut import FakeDonut # noqa: E402
TOKEN = "test-token-abc123"
@pytest.fixture
def fake() -> Iterator[FakeDonut]:
server = FakeDonut().start()
try:
yield server
finally:
server.stop()
@pytest.fixture
def client(fake: FakeDonut) -> Iterator[DonutClient]:
with DonutClient(token=TOKEN, port=fake.port, timeout=5.0, env={}) as connected:
yield connected
+144
View File
@@ -0,0 +1,144 @@
"""A stand-in for the desktop app's local REST API.
It records what the client sent, byte for byte, and answers with whatever the
test queued. Nothing here reaches the network: it binds an ephemeral loopback
port and is torn down with the test.
"""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, urlsplit
@dataclass
class RecordedRequest:
method: str
target: str
headers: Dict[str, str]
body: bytes
@property
def path(self) -> str:
return urlsplit(self.target).path
@property
def query(self) -> Dict[str, str]:
return dict(parse_qsl(urlsplit(self.target).query, keep_blank_values=True))
@property
def json(self) -> Any:
if not self.body:
return None
return json.loads(self.body.decode("utf-8"))
def header(self, name: str) -> Optional[str]:
for key, value in self.headers.items():
if key.lower() == name.lower():
return value
return None
@dataclass
class QueuedResponse:
status: int = 200
body: str = ""
headers: Tuple[Tuple[str, str], ...] = ()
content_type: str = "application/json"
@dataclass
class FakeDonut:
"""Queue responses, then read :attr:`requests` back."""
requests: List[RecordedRequest] = field(default_factory=list)
responses: List[QueuedResponse] = field(default_factory=list)
_server: Optional[ThreadingHTTPServer] = None
_thread: Optional[threading.Thread] = None
def enqueue_json(self, payload: Any, status: int = 200) -> None:
self.responses.append(QueuedResponse(status=status, body=json.dumps(payload)))
def enqueue_empty(self, status: int = 204) -> None:
self.responses.append(QueuedResponse(status=status, body=""))
def enqueue_error(
self,
status: int,
body: str = "",
headers: Tuple[Tuple[str, str], ...] = (),
) -> None:
self.responses.append(
QueuedResponse(status=status, body=body, headers=headers, content_type="text/plain")
)
@property
def port(self) -> int:
assert self._server is not None, "the fake server is not running"
return self._server.server_address[1]
@property
def last(self) -> RecordedRequest:
assert self.requests, "the client sent nothing"
return self.requests[-1]
def start(self) -> "FakeDonut":
fake = self
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_args: Any) -> None:
"""Keep the test output clean."""
def _handle(self) -> None:
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b""
fake.requests.append(
RecordedRequest(
method=self.command,
target=self.path,
headers={key: value for key, value in self.headers.items()},
body=body,
)
)
queued = fake.responses.pop(0) if fake.responses else QueuedResponse(body="{}")
payload = queued.body.encode("utf-8")
self.send_response(queued.status)
for name, value in queued.headers:
self.send_header(name, value)
if payload:
self.send_header("Content-Type", queued.content_type)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
if payload:
self.wfile.write(payload)
do_GET = _handle
do_POST = _handle
do_PUT = _handle
do_DELETE = _handle
do_PATCH = _handle
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
# A short poll interval so `shutdown()` returns promptly: the default
# 0.5s would add half a second to the teardown of every single test.
self._thread = threading.Thread(
target=self._server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True
)
self._thread.start()
return self
def stop(self) -> None:
if self._server is not None:
self._server.shutdown()
self._server.server_close()
self._server = None
if self._thread is not None:
self._thread.join(timeout=5)
self._thread = None
+83
View File
@@ -0,0 +1,83 @@
"""Where the token and the port come from, and in what order."""
from __future__ import annotations
import pytest
from fake_donut import FakeDonut
from donutbrowser import DEFAULT_HOST, DEFAULT_PORT, DonutClient, DonutError
def test_arguments_are_used_as_given() -> None:
client = DonutClient(token="from-argument", port=12345, env={})
assert client.token == "from-argument"
assert client.port == 12345
assert client.host == DEFAULT_HOST
assert client.base_url == "http://127.0.0.1:12345"
def test_the_environment_fills_in_what_was_not_passed() -> None:
client = DonutClient(env={"DONUT_API_TOKEN": "from-env", "DONUT_API_PORT": "13579"})
assert client.token == "from-env"
assert client.port == 13579
def test_arguments_win_over_the_environment() -> None:
client = DonutClient(
token="from-argument",
port=111,
env={"DONUT_API_TOKEN": "from-env", "DONUT_API_PORT": "222"},
)
assert client.token == "from-argument"
assert client.port == 111
def test_the_port_falls_back_to_the_app_default() -> None:
client = DonutClient(env={"DONUT_API_TOKEN": "t"})
assert client.port == DEFAULT_PORT == 10108
def test_a_base_url_overrides_host_and_port() -> None:
client = DonutClient(
base_url="http://127.0.0.1:9999/donut",
token="t",
env={"DONUT_API_PORT": "222"},
)
assert client.port == 9999
assert client.base_url == "http://127.0.0.1:9999/donut"
def test_a_base_url_prefix_is_kept_on_every_path(fake: FakeDonut) -> None:
with DonutClient(
base_url=f"http://127.0.0.1:{fake.port}/donut", token="t", timeout=5.0, env={}
) as client:
client.list_profiles()
assert fake.last.path == "/donut/v1/profiles"
def test_an_unusable_port_in_the_environment_is_reported() -> None:
with pytest.raises(DonutError) as raised:
DonutClient(env={"DONUT_API_TOKEN": "t", "DONUT_API_PORT": "not-a-number"})
assert "DONUT_API_PORT" in str(raised.value)
def test_an_unsupported_scheme_is_refused() -> None:
with pytest.raises(DonutError):
DonutClient(base_url="ftp://127.0.0.1:9999", token="t", env={})
def test_the_websocket_address_is_built_from_the_same_base() -> None:
client = DonutClient(token="t", port=10108, env={})
assert (
client.remote_session_cdp_url("s 1")
== "ws://127.0.0.1:10108/v1/remote-sessions/s%201/cdp"
)
def test_a_reopened_client_still_works(fake: FakeDonut) -> None:
"""`close()` drops the socket; the next call has to open a new one."""
with DonutClient(token="t", port=fake.port, timeout=5.0, env={}) as client:
client.list_profiles()
client.close()
client.list_profiles()
assert len(fake.requests) == 2
+73
View File
@@ -0,0 +1,73 @@
"""The SDK cannot silently drift from the app's API.
``sdk/api-paths.json`` is generated from ``src-tauri/src/api_server.rs`` and
lists every operation the desktop app publishes. These tests hold it against
the SDK's own table in both directions, so a new endpoint in the app fails here
until it is wrapped or deliberately omitted with a reason.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, Set, Tuple
from donutbrowser import DonutClient
from donutbrowser.coverage import OMITTED, OPERATIONS
SNAPSHOT = Path(__file__).resolve().parents[2] / "api-paths.json"
def published() -> Set[Tuple[str, str]]:
document: Dict[str, Any] = json.loads(SNAPSHOT.read_text(encoding="utf-8"))
return {
(operation["method"], operation["path"]) for operation in document["operations"]
}
def test_the_snapshot_is_readable_and_not_empty() -> None:
document = json.loads(SNAPSHOT.read_text(encoding="utf-8"))
assert document["source"] == "src-tauri/src/api_server.rs"
assert document["operation_count"] == len(document["operations"])
assert document["operation_count"] > 0
assert len(published()) == document["operation_count"], "the app has two identical operations"
def test_every_published_operation_is_wrapped_or_omitted() -> None:
known = set(OPERATIONS) | set(OMITTED)
missing = sorted(published() - known)
assert not missing, (
"the app publishes operations this SDK does not handle: "
f"{missing}. Wrap each one, or add it to coverage.OMITTED with a reason."
)
def test_the_sdk_claims_nothing_the_app_does_not_publish() -> None:
stale = sorted((set(OPERATIONS) | set(OMITTED)) - published())
assert not stale, (
"this SDK handles operations the app no longer publishes: "
f"{stale}. Regenerate the snapshot with sdk/tools/extract-api-paths.py, "
"then drop or fix each entry."
)
def test_an_operation_is_either_wrapped_or_omitted_but_not_both() -> None:
both = sorted(set(OPERATIONS) & set(OMITTED))
assert not both, f"listed twice: {both}"
def test_every_omission_gives_a_reason() -> None:
for operation, reason in OMITTED.items():
assert len(reason.strip()) > 40, f"{operation} is omitted without a real reason"
def test_every_wrapped_operation_names_a_real_method() -> None:
for operation, method_name in OPERATIONS.items():
attribute = getattr(DonutClient, method_name, None)
assert callable(attribute), f"{operation} names {method_name}, which is not a method"
def test_no_two_operations_share_a_method() -> None:
names = list(OPERATIONS.values())
duplicates = sorted({name for name in names if names.count(name) > 1})
assert not duplicates, f"one method is claimed by several operations: {duplicates}"
+168
View File
@@ -0,0 +1,168 @@
"""Each status the app documents raises its own exception."""
from __future__ import annotations
import json
import pytest
from fake_donut import FakeDonut, QueuedResponse
from donutbrowser import (
BadGateway,
Conflict,
DonutAPIError,
DonutClient,
DonutConnectionError,
DonutError,
Forbidden,
NotFound,
PaymentRequired,
RateLimited,
RequestTimeout,
ServerError,
ServiceUnavailable,
Unauthorized,
ValidationError,
)
STATUS_TO_ERROR = [
(400, ValidationError),
(401, Unauthorized),
(402, PaymentRequired),
(403, Forbidden),
(404, NotFound),
(408, RequestTimeout),
(409, Conflict),
(429, RateLimited),
(500, ServerError),
(502, BadGateway),
(503, ServiceUnavailable),
]
@pytest.mark.parametrize("status,expected", STATUS_TO_ERROR)
def test_status_maps_to_its_exception(
client: DonutClient, fake: FakeDonut, status: int, expected: type
) -> None:
fake.enqueue_error(status, "something went wrong")
with pytest.raises(expected) as raised:
client.list_profiles()
assert raised.value.status == status
assert raised.value.body == "something went wrong"
assert raised.value.method == "GET"
assert raised.value.path == "/v1/profiles"
def test_every_error_is_a_donut_error(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(404, "PROFILE_NOT_FOUND")
with pytest.raises(DonutError):
client.get_profile("nope")
def test_the_five_hundreds_share_one_base(client: DonutClient, fake: FakeDonut) -> None:
"""`except ServerError` has to catch 502 and 503 as well as 500."""
for status in (500, 502, 503):
fake.enqueue_error(status, "upstream")
with pytest.raises(ServerError):
client.list_profiles()
def test_rate_limited_carries_retry_after(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(
429,
"automation request rate limit exceeded",
headers=(("Retry-After", "42"),),
)
with pytest.raises(RateLimited) as raised:
client.run_profile("p1")
assert raised.value.retry_after == 42
def test_rate_limited_without_the_header_is_still_raised(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_error(429, "slow down")
with pytest.raises(RateLimited) as raised:
client.run_profile("p1")
assert raised.value.retry_after is None
def test_an_unreadable_retry_after_does_not_break_the_error(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_error(429, "slow down", headers=(("Retry-After", "Wed, 21 Oct 2026 07:28:00 GMT"),))
with pytest.raises(RateLimited) as raised:
client.run_profile("p1")
assert raised.value.retry_after is None
def test_a_structured_code_body_is_decoded(client: DonutClient, fake: FakeDonut) -> None:
"""The app shares `{"code": ...}` strings with its own frontend."""
fake.enqueue_error(400, json.dumps({"code": "NAME_CANNOT_BE_EMPTY"}))
with pytest.raises(ValidationError) as raised:
client.create_group(name="")
assert raised.value.code == "NAME_CANNOT_BE_EMPTY"
assert raised.value.params == {}
def test_a_structured_code_body_keeps_its_params(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(409, json.dumps({"code": "PROFILE_LOCKED_BY_MEMBER", "params": {"n": "5"}}))
with pytest.raises(Conflict) as raised:
client.run_profile("p1")
assert raised.value.code == "PROFILE_LOCKED_BY_MEMBER"
assert raised.value.params == {"n": "5"}
def test_a_plain_text_body_leaves_code_unset(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(400, "invalid browser")
with pytest.raises(ValidationError) as raised:
client.create_profile(name="x", browser="chromium")
assert raised.value.code is None
assert raised.value.body == "invalid browser"
def test_an_undocumented_status_still_raises_something_catchable(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_error(418, "teapot")
with pytest.raises(DonutAPIError) as raised:
client.list_profiles()
assert raised.value.status == 418
def test_an_undocumented_server_status_is_a_server_error(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_error(504, "gateway timeout")
with pytest.raises(ServerError):
client.list_profiles()
def test_the_message_names_the_call(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(404, "Profile not found")
with pytest.raises(NotFound) as raised:
client.get_profile("missing")
assert "404" in str(raised.value)
assert "GET /v1/profiles/missing" in str(raised.value)
def test_an_unreachable_app_is_not_an_api_error(fake: FakeDonut) -> None:
port = fake.port
fake.stop()
with DonutClient(token="t", port=port, timeout=2.0, env={}) as client:
with pytest.raises(DonutConnectionError) as raised:
client.list_profiles()
assert "Local API" in str(raised.value)
def test_a_missing_token_fails_before_any_request() -> None:
with pytest.raises(DonutError) as raised:
DonutClient(env={})
assert "DONUT_API_TOKEN" in str(raised.value)
def test_a_non_json_answer_is_reported_as_such(client: DonutClient, fake: FakeDonut) -> None:
fake.responses.append(QueuedResponse(status=200, body="<html>nope</html>"))
with pytest.raises(DonutError) as raised:
client.list_profiles()
assert "not JSON" in str(raised.value)
+721
View File
@@ -0,0 +1,721 @@
"""Every client method sends exactly the request the app documents.
The table below is the whole public surface. Each row names a method, the
arguments to call it with, and the request that must appear on the wire: the
verb, the concrete path, the query string and the JSON body. ``operation`` is
the path template the app publishes, which ties this file to
``donutbrowser.coverage.OPERATIONS`` and, through it, to ``sdk/api-paths.json``.
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Tuple
import pytest
from fake_donut import FakeDonut
from donutbrowser import DonutClient
from donutbrowser.coverage import OPERATIONS
Case = Tuple[
str, # client method
Tuple[Any, ...], # positional arguments
Dict[str, Any], # keyword arguments
str, # expected verb
str, # expected concrete path
Optional[Dict[str, Any]], # expected JSON body, or None for no body
Dict[str, str], # expected query string
str, # operation template, as published by the app
]
LOCATOR = {"role": "button", "name": "Sign in"}
CASES: List[Case] = [
# -- profiles ----------------------------------------------------------
("list_profiles", (), {}, "GET", "/v1/profiles", None, {}, "/v1/profiles"),
("get_profile", ("p1",), {}, "GET", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"),
(
"create_profile",
(),
{"name": "Shopper", "browser": "wayfern", "tags": ["eu"], "ephemeral": True},
"POST",
"/v1/profiles",
{"name": "Shopper", "browser": "wayfern", "tags": ["eu"], "ephemeral": True},
{},
"/v1/profiles",
),
(
"create_profile",
(),
{"name": "Bare", "browser": "wayfern"},
"POST",
"/v1/profiles",
{"name": "Bare", "browser": "wayfern"},
{},
"/v1/profiles",
),
(
"update_profile",
("p1",),
{"name": "Renamed", "proxy_id": "", "clear_on_close": False},
"PUT",
"/v1/profiles/p1",
{"name": "Renamed", "proxy_id": "", "clear_on_close": False},
{},
"/v1/profiles/{id}",
),
("delete_profile", ("p1",), {}, "DELETE", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"),
(
"run_profile",
("p1",),
{"url": "https://example.com", "headless": True},
"POST",
"/v1/profiles/p1/run",
{"url": "https://example.com", "headless": True},
{},
"/v1/profiles/{id}/run",
),
(
"run_profile_remote",
("p1",),
{"url": "https://example.com"},
"POST",
"/v1/profiles/p1/run-remote",
{"url": "https://example.com"},
{},
"/v1/profiles/{id}/run-remote",
),
(
"set_profile_cloud_sync",
("p1",),
{"mode": "Regular"},
"POST",
"/v1/profiles/p1/cloud-sync",
{"mode": "Regular"},
{},
"/v1/profiles/{id}/cloud-sync",
),
(
"open_url",
("p1", "https://example.com/page"),
{},
"POST",
"/v1/profiles/p1/open-url",
{"url": "https://example.com/page"},
{},
"/v1/profiles/{id}/open-url",
),
(
"kill_profile",
("p1",),
{},
"POST",
"/v1/profiles/p1/kill",
None,
{},
"/v1/profiles/{id}/kill",
),
(
"batch_run_profiles",
(["p1", "p2"],),
{"headless": False},
"POST",
"/v1/profiles/batch/run",
{"profile_ids": ["p1", "p2"], "headless": False},
{},
"/v1/profiles/batch/run",
),
(
"batch_stop_profiles",
(["p1", "p2"],),
{},
"POST",
"/v1/profiles/batch/stop",
{"profile_ids": ["p1", "p2"]},
{},
"/v1/profiles/batch/stop",
),
(
"distribute_proxies",
([{"profile_id": "p1", "proxy_id": "x1"}, {"profile_id": "p2", "proxy_id": "x2"}],),
{},
"POST",
"/v1/profiles/distribute-proxies",
{
"pairs": [
{"profile_id": "p1", "proxy_id": "x1"},
{"profile_id": "p2", "proxy_id": "x2"},
]
},
{},
"/v1/profiles/distribute-proxies",
),
(
"detect_import_profiles",
(),
{"folder": "/Users/x/Chrome"},
"GET",
"/v1/profiles/import/detect",
None,
{"folder": "/Users/x/Chrome"},
"/v1/profiles/import/detect",
),
(
"detect_import_profiles",
(),
{},
"GET",
"/v1/profiles/import/detect",
None,
{},
"/v1/profiles/import/detect",
),
(
"import_profiles",
([{"source_path": "/tmp/src", "new_profile_name": "Imported"}],),
{"duplicate_strategy": "skip"},
"POST",
"/v1/profiles/import",
{
"items": [{"source_path": "/tmp/src", "new_profile_name": "Imported"}],
"duplicate_strategy": "skip",
},
{},
"/v1/profiles/import",
),
(
"import_profile_cookies",
("p1",),
{"content": "[]"},
"POST",
"/v1/profiles/p1/cookies/import",
{"content": "[]"},
{},
"/v1/profiles/{id}/cookies/import",
),
# -- agent -------------------------------------------------------------
(
"agent_perceive",
("p1",),
{"viewport_only": True, "max_bytes": 2048},
"POST",
"/v1/profiles/p1/agent/perceive",
{"max_bytes": 2048, "viewport_only": True},
{},
"/v1/profiles/{id}/agent/perceive",
),
(
"agent_perceive",
("p1",),
{},
"POST",
"/v1/profiles/p1/agent/perceive",
{},
{},
"/v1/profiles/{id}/agent/perceive",
),
(
"agent_resolve_locator",
("p1",),
{"locator": LOCATOR, "candidate_limit": 5},
"POST",
"/v1/profiles/p1/agent/resolve-locator",
{"locator": LOCATOR, "candidate_limit": 5},
{},
"/v1/profiles/{id}/agent/resolve-locator",
),
(
"agent_click",
("p1",),
{"locator": LOCATOR, "button": "right", "click_count": 2},
"POST",
"/v1/profiles/p1/agent/click",
{"locator": LOCATOR, "button": "right", "click_count": 2},
{},
"/v1/profiles/{id}/agent/click",
),
(
"agent_type",
("p1",),
{"locator": LOCATOR, "text": "hello", "clear_first": False, "wpm": 55.0},
"POST",
"/v1/profiles/p1/agent/type",
{"locator": LOCATOR, "text": "hello", "clear_first": False, "wpm": 55.0},
{},
"/v1/profiles/{id}/agent/type",
),
(
"agent_extract",
("p1",),
{
"container": {"role": "listitem"},
"field_map": [{"key": "title", "locator": {"role": "heading"}, "source": "text"}],
"max_pages": 3,
},
"POST",
"/v1/profiles/p1/agent/extract",
{
"container": {"role": "listitem"},
"field_map": [{"key": "title", "locator": {"role": "heading"}, "source": "text"}],
"max_pages": 3,
},
{},
"/v1/profiles/{id}/agent/extract",
),
(
"agent_pick",
("p1",),
{"timeout_ms": 15000},
"POST",
"/v1/profiles/p1/agent/pick",
{"timeout_ms": 15000},
{},
"/v1/profiles/{id}/agent/pick",
),
# -- remote sessions ---------------------------------------------------
(
"list_remote_sessions",
(),
{},
"GET",
"/v1/remote-sessions",
None,
{},
"/v1/remote-sessions",
),
(
"get_remote_session",
("s1",),
{},
"GET",
"/v1/remote-sessions/s1",
None,
{},
"/v1/remote-sessions/{id}",
),
(
"stop_remote_session",
("s1",),
{},
"DELETE",
"/v1/remote-sessions/s1",
None,
{},
"/v1/remote-sessions/{id}",
),
("get_remote_hours", (), {}, "GET", "/v1/remote-hours", None, {}, "/v1/remote-hours"),
# -- cookie bot --------------------------------------------------------
(
"list_cookie_bot_schedules",
(),
{"scope": "team"},
"GET",
"/v1/cookie-bot/schedules",
None,
{"scope": "team"},
"/v1/cookie-bot/schedules",
),
(
"get_cookie_bot_schedule",
("p1",),
{},
"GET",
"/v1/cookie-bot/schedules/p1",
None,
{},
"/v1/cookie-bot/schedules/{profile_id}",
),
(
"set_cookie_bot_schedule",
("p1",),
{
"enabled": True,
"run_at_minute": 120,
"days_mask": 31,
"timezone": "Europe/Berlin",
"preset": "steady",
"max_minutes": 45,
"sites": ["https://example.com"],
"acknowledge_conflict": True,
},
"PUT",
"/v1/cookie-bot/schedules/p1",
{
"enabled": True,
"run_at_minute": 120,
"days_mask": 31,
"timezone": "Europe/Berlin",
"preset": "steady",
"max_minutes": 45,
"sites": ["https://example.com"],
"acknowledge_conflict": True,
},
{},
"/v1/cookie-bot/schedules/{profile_id}",
),
(
"delete_cookie_bot_schedule",
("p1",),
{},
"DELETE",
"/v1/cookie-bot/schedules/p1",
None,
{},
"/v1/cookie-bot/schedules/{profile_id}",
),
(
"get_cookie_bot_conflicts",
("p1",),
{"run_at_minute": 90, "timezone": "UTC", "days_mask": 7},
"GET",
"/v1/cookie-bot/conflicts",
None,
{"profile_id": "p1", "run_at_minute": "90", "timezone": "UTC", "days_mask": "7"},
"/v1/cookie-bot/conflicts",
),
(
"list_cookie_bot_runs",
(),
{"profile_id": "p1", "limit": 10, "before": "cursor-1"},
"GET",
"/v1/cookie-bot/runs",
None,
{"profile_id": "p1", "limit": "10", "before": "cursor-1"},
"/v1/cookie-bot/runs",
),
(
"start_cookie_bot_run",
(),
{"profile_id": "p1", "max_minutes": 30},
"POST",
"/v1/cookie-bot/runs",
{"profile_id": "p1", "max_minutes": 30},
{},
"/v1/cookie-bot/runs",
),
(
"cancel_cookie_bot_run",
("r1",),
{},
"DELETE",
"/v1/cookie-bot/runs/r1",
None,
{},
"/v1/cookie-bot/runs/{run_id}",
),
(
"list_cookie_bot_presets",
(),
{},
"GET",
"/v1/cookie-bot/presets",
None,
{},
"/v1/cookie-bot/presets",
),
(
"get_cookie_bot_usage",
(),
{"period": "2026-08"},
"GET",
"/v1/cookie-bot/usage",
None,
{"period": "2026-08"},
"/v1/cookie-bot/usage",
),
# -- groups and tags ---------------------------------------------------
("list_groups", (), {}, "GET", "/v1/groups", None, {}, "/v1/groups"),
("get_group", ("g1",), {}, "GET", "/v1/groups/g1", None, {}, "/v1/groups/{id}"),
("create_group", (), {"name": "Retail"}, "POST", "/v1/groups", {"name": "Retail"}, {}, "/v1/groups"),
(
"update_group",
("g1",),
{"name": "Retail EU"},
"PUT",
"/v1/groups/g1",
{"name": "Retail EU"},
{},
"/v1/groups/{id}",
),
("delete_group", ("g1",), {}, "DELETE", "/v1/groups/g1", None, {}, "/v1/groups/{id}"),
("list_tags", (), {}, "GET", "/v1/tags", None, {}, "/v1/tags"),
# -- proxies -----------------------------------------------------------
("list_proxies", (), {}, "GET", "/v1/proxies", None, {}, "/v1/proxies"),
("get_proxy", ("x1",), {}, "GET", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"),
(
"create_proxy",
(),
{"name": "EU", "proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080}},
"POST",
"/v1/proxies",
{"name": "EU", "proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080}},
{},
"/v1/proxies",
),
(
"update_proxy",
("x1",),
{"name": "EU 2"},
"PUT",
"/v1/proxies/x1",
{"name": "EU 2"},
{},
"/v1/proxies/{id}",
),
("delete_proxy", ("x1",), {}, "DELETE", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"),
(
"import_proxies",
(),
{"format": "txt", "content": "h:1:u:p", "name_prefix": "EU"},
"POST",
"/v1/proxies/import",
{"format": "txt", "content": "h:1:u:p", "name_prefix": "EU"},
{},
"/v1/proxies/import",
),
# -- vpns --------------------------------------------------------------
("list_vpns", (), {}, "GET", "/v1/vpns", None, {}, "/v1/vpns"),
("get_vpn", ("v1",), {}, "GET", "/v1/vpns/v1", None, {}, "/v1/vpns/{id}"),
("export_vpn", ("v1",), {}, "GET", "/v1/vpns/v1/export", None, {}, "/v1/vpns/{id}/export"),
(
"import_vpn",
(),
{"content": "[Interface]", "filename": "eu.conf"},
"POST",
"/v1/vpns/import",
{"content": "[Interface]", "filename": "eu.conf"},
{},
"/v1/vpns/import",
),
(
"create_vpn",
(),
{"name": "EU", "vpn_type": "WireGuard", "config_data": "[Interface]"},
"POST",
"/v1/vpns",
{"name": "EU", "vpn_type": "WireGuard", "config_data": "[Interface]"},
{},
"/v1/vpns",
),
(
"update_vpn",
("v1",),
{"name": "EU 2"},
"PUT",
"/v1/vpns/v1",
{"name": "EU 2"},
{},
"/v1/vpns/{id}",
),
("delete_vpn", ("v1",), {}, "DELETE", "/v1/vpns/v1", None, {}, "/v1/vpns/{id}"),
# -- extensions --------------------------------------------------------
("list_extensions", (), {}, "GET", "/v1/extensions", None, {}, "/v1/extensions"),
("get_extension", ("e1",), {}, "GET", "/v1/extensions/e1", None, {}, "/v1/extensions/{id}"),
(
"create_extension",
(),
{"name": "Blocker", "file_name": "b.crx", "file_data_base64": "AAAA"},
"POST",
"/v1/extensions",
{"name": "Blocker", "file_name": "b.crx", "file_data_base64": "AAAA"},
{},
"/v1/extensions",
),
(
"update_extension",
("e1",),
{"name": "Blocker 2", "link": True},
"PUT",
"/v1/extensions/e1",
{"name": "Blocker 2", "link": True},
{},
"/v1/extensions/{id}",
),
(
"delete_extension",
("e1",),
{},
"DELETE",
"/v1/extensions/e1",
None,
{},
"/v1/extensions/{id}",
),
(
"list_extension_groups",
(),
{},
"GET",
"/v1/extension-groups",
None,
{},
"/v1/extension-groups",
),
(
"get_extension_group",
("eg1",),
{},
"GET",
"/v1/extension-groups/eg1",
None,
{},
"/v1/extension-groups/{id}",
),
(
"create_extension_group",
(),
{"name": "Adblock set"},
"POST",
"/v1/extension-groups",
{"name": "Adblock set"},
{},
"/v1/extension-groups",
),
(
"update_extension_group",
("eg1",),
{"extension_ids": ["e1", "e2"]},
"PUT",
"/v1/extension-groups/eg1",
{"extension_ids": ["e1", "e2"]},
{},
"/v1/extension-groups/{id}",
),
(
"delete_extension_group",
("eg1",),
{},
"DELETE",
"/v1/extension-groups/eg1",
None,
{},
"/v1/extension-groups/{id}",
),
(
"add_extension_to_group",
("eg1", "e1"),
{},
"POST",
"/v1/extension-groups/eg1/extensions/e1",
None,
{},
"/v1/extension-groups/{id}/extensions/{extension_id}",
),
(
"remove_extension_from_group",
("eg1", "e1"),
{},
"DELETE",
"/v1/extension-groups/eg1/extensions/e1",
None,
{},
"/v1/extension-groups/{id}/extensions/{extension_id}",
),
# -- browsers ----------------------------------------------------------
(
"download_browser",
(),
{"browser": "wayfern", "version": "152.0.1"},
"POST",
"/v1/browsers/download",
{"browser": "wayfern", "version": "152.0.1"},
{},
"/v1/browsers/download",
),
(
"list_browser_versions",
("wayfern",),
{},
"GET",
"/v1/browsers/wayfern/versions",
None,
{},
"/v1/browsers/{browser}/versions",
),
(
"is_browser_downloaded",
("wayfern", "152.0.1"),
{},
"GET",
"/v1/browsers/wayfern/versions/152.0.1/downloaded",
None,
{},
"/v1/browsers/{browser}/versions/{version}/downloaded",
),
]
@pytest.mark.parametrize(
"case", CASES, ids=[f"{case[0]}[{index}]" for index, case in enumerate(CASES)]
)
def test_method_sends_the_documented_request(
client: DonutClient, fake: FakeDonut, case: Case
) -> None:
name, args, kwargs, verb, path, body, query, operation = case
getattr(client, name)(*args, **kwargs)
sent = fake.last
assert sent.method == verb
assert sent.path == path
assert sent.query == query
assert sent.json == body
assert OPERATIONS[(verb, operation)] == name
def test_every_client_method_is_exercised_here() -> None:
"""No method may be added to the table of operations without a case above."""
covered = {case[0] for case in CASES}
missing = sorted(set(OPERATIONS.values()) - covered)
assert not missing, f"these wrapped operations have no request test: {missing}"
def test_the_token_travels_as_a_bearer_header(client: DonutClient, fake: FakeDonut) -> None:
client.list_profiles()
sent = fake.last
assert sent.header("Authorization") == "Bearer test-token-abc123"
assert sent.header("Accept") == "application/json"
assert sent.header("Content-Type") is None, "a GET must not claim to carry JSON"
def test_a_body_is_sent_as_json(client: DonutClient, fake: FakeDonut) -> None:
client.create_group(name="Retail")
sent = fake.last
assert sent.header("Content-Type") == "application/json"
assert json.loads(sent.body.decode()) == {"name": "Retail"}
def test_path_ids_are_escaped(client: DonutClient, fake: FakeDonut) -> None:
"""An id can never break out of its own path segment."""
client.get_profile("a/b c?d")
assert fake.last.path == "/v1/profiles/a%2Fb%20c%3Fd"
def test_none_arguments_are_left_out_of_the_body(client: DonutClient, fake: FakeDonut) -> None:
client.update_profile("p1", name="Only this")
assert fake.last.json == {"name": "Only this"}
def test_an_empty_string_still_reaches_the_app(client: DonutClient, fake: FakeDonut) -> None:
"""`proxy_id=""` is how the app is told to detach a proxy, so it must survive."""
client.update_profile("p1", proxy_id="")
assert fake.last.json == {"proxy_id": ""}
def test_a_no_content_answer_becomes_none(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_empty(204)
assert client.delete_profile("p1") is None
def test_a_json_answer_is_returned_as_sent(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_json({"profiles": [{"id": "p1", "name": "Shopper"}], "total": 1})
assert client.list_profiles() == {
"profiles": [{"id": "p1", "name": "Shopper"}],
"total": 1,
}
def test_a_bare_boolean_answer_is_returned(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_json(True)
assert client.is_browser_downloaded("wayfern", "152.0.1") is True
+92
View File
@@ -0,0 +1,92 @@
"""`with client.run(...)` launches, hands over the CDP endpoint, and stops."""
from __future__ import annotations
import pytest
from fake_donut import FakeDonut
from donutbrowser import Conflict, DonutClient, DonutError
RUN_BODY = {"profile_id": "p1", "remote_debugging_port": 9222, "headless": True}
def test_the_block_gets_the_cdp_endpoint(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_json(RUN_BODY)
fake.enqueue_empty(204)
with client.run("p1", url="https://example.com", headless=True) as session:
assert session.remote_debugging_port == 9222
assert session.headless is True
assert session.cdp_url == "http://127.0.0.1:9222"
assert session.response == RUN_BODY
assert [(sent.method, sent.path) for sent in fake.requests] == [
("POST", "/v1/profiles/p1/run"),
("POST", "/v1/profiles/p1/kill"),
]
assert fake.requests[0].json == {"url": "https://example.com", "headless": True}
def test_nothing_launches_until_the_block_is_entered(
client: DonutClient, fake: FakeDonut
) -> None:
session = client.run("p1")
assert session.remote_debugging_port is None
assert fake.requests == []
def test_the_browser_is_stopped_when_the_block_raises(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_json(RUN_BODY)
fake.enqueue_empty(204)
with pytest.raises(ZeroDivisionError):
with client.run("p1"):
raise ZeroDivisionError("the body failed")
assert [sent.path for sent in fake.requests] == [
"/v1/profiles/p1/run",
"/v1/profiles/p1/kill",
]
def test_a_failed_stop_never_hides_why_the_block_failed(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_json(RUN_BODY)
fake.enqueue_error(409, "PROFILE_LOCKED_ELSEWHERE")
session = client.run("p1")
with pytest.raises(ZeroDivisionError):
with session:
raise ZeroDivisionError("the body failed")
assert isinstance(session.cleanup_error, Conflict)
def test_a_failed_stop_is_raised_when_the_block_was_fine(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_json(RUN_BODY)
fake.enqueue_error(503, "the fleet could not be reached")
with pytest.raises(DonutError):
with client.run("p1"):
pass
def test_a_failed_launch_stops_nothing(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(409, "PROFILE_RUNNING")
with pytest.raises(Conflict):
with client.run("p1"):
pytest.fail("the block must not run when the launch failed")
assert [sent.path for sent in fake.requests] == ["/v1/profiles/p1/run"]
def test_the_cdp_url_is_refused_before_the_block(client: DonutClient) -> None:
session = client.run("p1")
with pytest.raises(DonutError):
_ = session.cdp_url