mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-10 19:58:57 +02:00
refactor: cleanup
This commit is contained in:
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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."
|
||||
),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user