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