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
+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