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
+11
View File
@@ -0,0 +1,11 @@
# Build and test artifacts for the two standalone SDK packages. Neither is part
# of the pnpm workspace, so they carry their own ignores rather than adding
# Python and npm noise to the repository root.
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.venv/
build/
dist/
node_modules/
+285
View File
@@ -0,0 +1,285 @@
# Donut Browser SDKs
Two thin clients for the REST API that Donut Browser serves on this machine:
[`python/`](python) (`donutbrowser`) and [`node/`](node) (`@donutbrowser/sdk`).
They are deliberately thin. Every method is one request to one path that the
app publishes in its own `/openapi.json`, with the request and response shapes
taken from the Rust handlers in `src-tauri/src/api_server.rs`. Nothing is
cached, nothing is retried, and no endpoint is invented. What the two add on top
of a bare HTTP call is the part that is tedious to redo in every script:
- the bearer token and the port, read from arguments or the environment,
- one exception class per documented status, with `Retry-After` parsed and the
app's `{"code": ...}` error bodies decoded,
- a launch-and-stop helper, so a script cannot leave a browser running,
- a drift check that fails the tests when the app grows an endpoint the SDK
does not cover.
Neither package is part of the pnpm workspace. They build, test and publish on
their own, so they never slow the desktop app's own checks down.
## Switch the API on first
**The local REST API is off by default. It must be enabled in the app under
Settings → Integrations → Local API → "Enable Local API Server".**
That screen also shows the two things a client needs:
- the **port**, `10108` unless it was already taken or you changed it, and
- the **authentication token**, sent as `Authorization: Bearer <token>`.
The server binds `127.0.0.1` only, so it is never reachable from another
machine. Requests are also refused with `403` until the Wayfern terms have been
accepted in the app.
Both SDKs read arguments first, then the environment:
| Setting | Argument | Environment | Default |
| --- | --- | --- | --- |
| Token | `token` | `DONUT_API_TOKEN` | none; required |
| Port | `port` | `DONUT_API_PORT` | `10108` |
| Host | `host` | — | `127.0.0.1` |
`base_url` / `baseUrl` overrides host and port entirely, for the rare case of a
tunnel or a path prefix in front of the app.
## Python
Requires Python 3.10 or newer. **No runtime dependencies:** the client talks to
a loopback server on the same machine, so `http.client` from the standard
library is enough. That keeps `pip install donutbrowser` from dragging anything
into an automation environment, and it sidesteps a real trap — `urllib.request`
honours `http_proxy` from the environment, which would send calls meant for the
local app through whatever proxy the shell happens to have set.
```bash
cd sdk/python
pip install -e .
```
A worked example: launch a profile, drive the page through the agent endpoints,
and stop the browser.
```python
from donutbrowser import Conflict, DonutClient, NotFound, RateLimited
PROFILE_ID = "your-profile-id"
with DonutClient(token="...") as client:
# `run` starts the browser on entry and stops it on exit, even if the body
# raises. `session.cdp_url` is the DevTools endpoint the launch returned.
with client.run(PROFILE_ID, url="https://example.com", headless=True) as session:
print("CDP:", session.cdp_url)
# Read the page the way the agent sees it: roles, names, text, bounds.
page = client.agent_perceive(PROFILE_ID, viewport_only=True)
print(page["stats"]["returnedNodes"], "nodes,", len(page["text"]), "characters")
# Name an element without a selector, and check it is unambiguous.
search = {"role": "textbox", "nameContains": "Search"}
resolved = client.agent_resolve_locator(PROFILE_ID, locator=search)
assert resolved["matchCount"] == 1
client.agent_type(PROFILE_ID, locator=search, text="donut browser")
client.agent_click(PROFILE_ID, locator={"role": "button", "name": "Search"})
# Pull a table out of whatever came back.
rows = client.agent_extract(
PROFILE_ID,
container={"role": "listitem"},
field_map=[
{"key": "title", "locator": {"role": "heading"}, "source": "text"},
{"key": "link", "locator": {"role": "link"}, "source": "link"},
],
max_pages=3,
)
for row in rows["rows"]:
print(row["values"])
# The browser is stopped here.
```
Errors are classes, not status codes:
```python
try:
client.run_profile(PROFILE_ID)
except Conflict as busy:
print("someone else has it:", busy.code) # PROFILE_LOCKED_BY_MEMBER, ...
except RateLimited as limited:
print("wait", limited.retry_after, "seconds")
except NotFound:
print("no such profile")
```
### Tests
```bash
cd sdk/python
pip install -e ".[dev]"
pytest
```
## Node
Requires Node 22 or newer, for the built-in `fetch`. **No runtime
dependencies**; `typescript` is a development dependency and is needed only to
build `dist/` for publishing. The tests run straight from the TypeScript
sources through Node's own type stripping, so `npm test` works with nothing
installed at all.
```bash
cd sdk/node
npm install # only needed for `npm run build`
npm run build
```
The convenience helper is `withProfile(profileId, options, work)`, a callback
rather than `await using`. `await using` is not yet syntax any released V8
understands, so TypeScript has to down-level it — which would stop the sources
running under Node's type stripping, and with it `npm test` on a clean
checkout. The callback form works on every Node 22. A `RunSession` does also
implement `Symbol.asyncDispose`, so `await using` is there for anyone whose
toolchain already handles it.
```ts
import { Conflict, DonutClient, NotFound, RateLimited } from "@donutbrowser/sdk";
const PROFILE_ID = "your-profile-id";
const client = new DonutClient({ token: "..." });
// The browser starts before `work` runs and is stopped after it, even when it
// throws. `session.cdpUrl` is the DevTools endpoint the launch returned.
const titles = await client.withProfile(
PROFILE_ID,
{ url: "https://example.com", headless: true },
async (session) => {
console.log("CDP:", session.cdpUrl);
const page = await client.agentPerceive(PROFILE_ID, { viewport_only: true });
console.log(page.stats.returnedNodes, "nodes,", page.text.length, "characters");
const search = { role: "textbox", nameContains: "Search" };
const resolved = await client.agentResolveLocator(PROFILE_ID, { locator: search });
if (resolved.matchCount !== 1) {
throw new Error("the search box is ambiguous");
}
await client.agentType(PROFILE_ID, { locator: search, text: "donut browser" });
await client.agentClick(PROFILE_ID, {
locator: { role: "button", name: "Search" },
});
const extraction = await client.agentExtract(PROFILE_ID, {
container: { role: "listitem" },
field_map: [
{ key: "title", locator: { role: "heading" }, source: "text" },
{ key: "link", locator: { role: "link" }, source: "link" },
],
max_pages: 3,
});
return extraction.rows.map((row) => row.values.title);
},
);
// The browser is stopped here.
try {
await client.runProfile(PROFILE_ID);
} catch (error) {
if (error instanceof Conflict) {
console.log("someone else has it:", error.code);
} else if (error instanceof RateLimited) {
console.log("wait", error.retryAfter, "seconds");
} else if (error instanceof NotFound) {
console.log("no such profile");
} else {
throw error;
}
}
```
### Tests
```bash
cd sdk/node
npm test
```
`npm test` runs the TypeScript sources directly, which needs Node 22.18 or
newer (type stripping is unflagged from that release). The published package
ships compiled `.mjs`, so consumers only need Node 22.
## Errors
Both packages map the app's documented statuses onto the same set of classes.
The 5xx classes share one base, so a single `ServerError` branch catches every
server-side failure.
| Status | Python | Node | Meaning |
| ---: | --- | --- | --- |
| 400 | `ValidationError` | `ValidationError` | Malformed request, duplicate name, unsupported input |
| 401 | `Unauthorized` | `Unauthorized` | Missing or wrong bearer token |
| 402 | `PaymentRequired` | `PaymentRequired` | Automation needs an active paid plan |
| 403 | `Forbidden` | `Forbidden` | Wayfern terms not accepted, or not signed in |
| 404 | `NotFound` | `NotFound` | No entity with that id |
| 408 | `RequestTimeout` | `RequestTimeout` | `agent/pick` waited and nothing was picked |
| 409 | `Conflict` | `Conflict` | A browser, a teammate or a remote session holds the profile |
| 429 | `RateLimited` | `RateLimited` | Automation quota spent; `retry_after` / `retryAfter` |
| 500 | `ServerError` | `ServerError` | Internal failure |
| 502 | `BadGateway` | `BadGateway` | The browser or the relay answered wrongly |
| 503 | `ServiceUnavailable` | `ServiceUnavailable` | Cloud, fleet or lock service unreachable |
Anything else becomes `DonutAPIError` / `DonutApiError` (a `ServerError` for an
unrecognised 5xx), so a status added to the app later still arrives as
something a caller can catch. A transport failure — the app not running, the
API switched off, the wrong port — is `DonutConnectionError`, never an API
error, so "Donut is not there" is never confused with "Donut said no".
Every error carries `status`, `body`, `method` and `path`. When the body is one
of the app's structured `{"code": ..., "params": {...}}` strings, `code` and
`params` are filled in too.
A `503` from stopping something means the fleet could not be reached and the
remote browser is **still running**, not that it stopped.
## Staying in step with the app
`api-paths.json` in this directory lists every operation the app publishes. It
is generated from the `#[utoipa::path]` annotations and the `ApiDoc` `paths(...)`
list in `src-tauri/src/api_server.rs` — the two things the served
`/openapi.json` is actually built from — and the generator fails if a handler is
annotated but missing from `ApiDoc`, which is exactly how an endpoint silently
disappears from the spec.
```bash
python3 sdk/tools/extract-api-paths.py
```
Each SDK keeps its own table of operation to method (`donutbrowser.coverage` and
`OPERATIONS` in the Node package), and both test suites hold that table against
the snapshot in **both** directions:
- an operation in the snapshot that the SDK neither wraps nor lists as omitted
fails the suite, so a new endpoint cannot slip past unnoticed;
- an entry the app no longer publishes fails too, so a removed endpoint cannot
linger as a dead method;
- every wrapped operation must name a method that really exists, no two
operations may claim the same method, and every omission must carry a reason.
On top of that, one parameterised test per method drives it against a fake
server and asserts the exact verb, path, query string and JSON body it sends.
That is what ties the table to reality rather than to a comment.
Of the 71 published operations, 70 are wrapped. The one omission:
- `GET /v1/remote-sessions/{id}/cdp` is a WebSocket upgrade, not a request an
HTTP client can make, and bundling a websocket implementation would end the
zero-dependency promise for one endpoint. `remote_session_cdp_url()` /
`remoteSessionCdpUrl()` builds the `ws://` address instead, so a websocket
library of your choosing can connect — send the same `Authorization: Bearer`
header on the handshake.
## Tests
Both suites run offline against a fake HTTP server on an ephemeral loopback
port. Neither needs the desktop app, a browser, a network, or credentials.
+363
View File
@@ -0,0 +1,363 @@
{
"source": "src-tauri/src/api_server.rs",
"regenerate_with": "python3 sdk/tools/extract-api-paths.py",
"description": "Every operation the desktop app publishes in its /openapi.json. The SDK test suites assert this list and their own coverage tables match exactly, so an endpoint added to the app fails the SDK tests until it is either wrapped or deliberately listed as omitted.",
"operation_count": 71,
"operations": [
{
"operation_id": "download_browser_api",
"method": "POST",
"path": "/v1/browsers/download"
},
{
"operation_id": "get_browser_versions",
"method": "GET",
"path": "/v1/browsers/{browser}/versions"
},
{
"operation_id": "check_browser_downloaded",
"method": "GET",
"path": "/v1/browsers/{browser}/versions/{version}/downloaded"
},
{
"operation_id": "get_cookie_bot_conflicts",
"method": "GET",
"path": "/v1/cookie-bot/conflicts"
},
{
"operation_id": "list_cookie_bot_presets",
"method": "GET",
"path": "/v1/cookie-bot/presets"
},
{
"operation_id": "list_cookie_bot_runs",
"method": "GET",
"path": "/v1/cookie-bot/runs"
},
{
"operation_id": "start_cookie_bot_run",
"method": "POST",
"path": "/v1/cookie-bot/runs"
},
{
"operation_id": "cancel_cookie_bot_run",
"method": "DELETE",
"path": "/v1/cookie-bot/runs/{run_id}"
},
{
"operation_id": "list_cookie_bot_schedules",
"method": "GET",
"path": "/v1/cookie-bot/schedules"
},
{
"operation_id": "delete_cookie_bot_schedule",
"method": "DELETE",
"path": "/v1/cookie-bot/schedules/{profile_id}"
},
{
"operation_id": "get_cookie_bot_schedule",
"method": "GET",
"path": "/v1/cookie-bot/schedules/{profile_id}"
},
{
"operation_id": "set_cookie_bot_schedule",
"method": "PUT",
"path": "/v1/cookie-bot/schedules/{profile_id}"
},
{
"operation_id": "get_cookie_bot_usage",
"method": "GET",
"path": "/v1/cookie-bot/usage"
},
{
"operation_id": "get_extension_groups",
"method": "GET",
"path": "/v1/extension-groups"
},
{
"operation_id": "create_extension_group_api",
"method": "POST",
"path": "/v1/extension-groups"
},
{
"operation_id": "delete_extension_group_api",
"method": "DELETE",
"path": "/v1/extension-groups/{id}"
},
{
"operation_id": "get_extension_group_api",
"method": "GET",
"path": "/v1/extension-groups/{id}"
},
{
"operation_id": "update_extension_group_api",
"method": "PUT",
"path": "/v1/extension-groups/{id}"
},
{
"operation_id": "remove_extension_from_group_api",
"method": "DELETE",
"path": "/v1/extension-groups/{id}/extensions/{extension_id}"
},
{
"operation_id": "add_extension_to_group_api",
"method": "POST",
"path": "/v1/extension-groups/{id}/extensions/{extension_id}"
},
{
"operation_id": "get_extensions",
"method": "GET",
"path": "/v1/extensions"
},
{
"operation_id": "create_extension_api",
"method": "POST",
"path": "/v1/extensions"
},
{
"operation_id": "delete_extension_api",
"method": "DELETE",
"path": "/v1/extensions/{id}"
},
{
"operation_id": "get_extension_api",
"method": "GET",
"path": "/v1/extensions/{id}"
},
{
"operation_id": "update_extension_api",
"method": "PUT",
"path": "/v1/extensions/{id}"
},
{
"operation_id": "get_groups",
"method": "GET",
"path": "/v1/groups"
},
{
"operation_id": "create_group",
"method": "POST",
"path": "/v1/groups"
},
{
"operation_id": "delete_group",
"method": "DELETE",
"path": "/v1/groups/{id}"
},
{
"operation_id": "get_group",
"method": "GET",
"path": "/v1/groups/{id}"
},
{
"operation_id": "update_group",
"method": "PUT",
"path": "/v1/groups/{id}"
},
{
"operation_id": "get_profiles",
"method": "GET",
"path": "/v1/profiles"
},
{
"operation_id": "create_profile",
"method": "POST",
"path": "/v1/profiles"
},
{
"operation_id": "batch_run_profiles",
"method": "POST",
"path": "/v1/profiles/batch/run"
},
{
"operation_id": "batch_stop_profiles",
"method": "POST",
"path": "/v1/profiles/batch/stop"
},
{
"operation_id": "distribute_proxies",
"method": "POST",
"path": "/v1/profiles/distribute-proxies"
},
{
"operation_id": "import_profiles_api",
"method": "POST",
"path": "/v1/profiles/import"
},
{
"operation_id": "detect_import_profiles",
"method": "GET",
"path": "/v1/profiles/import/detect"
},
{
"operation_id": "delete_profile",
"method": "DELETE",
"path": "/v1/profiles/{id}"
},
{
"operation_id": "get_profile",
"method": "GET",
"path": "/v1/profiles/{id}"
},
{
"operation_id": "update_profile",
"method": "PUT",
"path": "/v1/profiles/{id}"
},
{
"operation_id": "agent_click_api",
"method": "POST",
"path": "/v1/profiles/{id}/agent/click"
},
{
"operation_id": "agent_extract_api",
"method": "POST",
"path": "/v1/profiles/{id}/agent/extract"
},
{
"operation_id": "agent_perceive_api",
"method": "POST",
"path": "/v1/profiles/{id}/agent/perceive"
},
{
"operation_id": "agent_pick_api",
"method": "POST",
"path": "/v1/profiles/{id}/agent/pick"
},
{
"operation_id": "agent_resolve_locator_api",
"method": "POST",
"path": "/v1/profiles/{id}/agent/resolve-locator"
},
{
"operation_id": "agent_type_api",
"method": "POST",
"path": "/v1/profiles/{id}/agent/type"
},
{
"operation_id": "set_profile_cloud_sync",
"method": "POST",
"path": "/v1/profiles/{id}/cloud-sync"
},
{
"operation_id": "import_profile_cookies",
"method": "POST",
"path": "/v1/profiles/{id}/cookies/import"
},
{
"operation_id": "kill_profile",
"method": "POST",
"path": "/v1/profiles/{id}/kill"
},
{
"operation_id": "open_url_in_profile",
"method": "POST",
"path": "/v1/profiles/{id}/open-url"
},
{
"operation_id": "run_profile",
"method": "POST",
"path": "/v1/profiles/{id}/run"
},
{
"operation_id": "run_profile_remote",
"method": "POST",
"path": "/v1/profiles/{id}/run-remote"
},
{
"operation_id": "get_proxies",
"method": "GET",
"path": "/v1/proxies"
},
{
"operation_id": "create_proxy",
"method": "POST",
"path": "/v1/proxies"
},
{
"operation_id": "import_proxies_api",
"method": "POST",
"path": "/v1/proxies/import"
},
{
"operation_id": "delete_proxy",
"method": "DELETE",
"path": "/v1/proxies/{id}"
},
{
"operation_id": "get_proxy",
"method": "GET",
"path": "/v1/proxies/{id}"
},
{
"operation_id": "update_proxy",
"method": "PUT",
"path": "/v1/proxies/{id}"
},
{
"operation_id": "get_remote_hours",
"method": "GET",
"path": "/v1/remote-hours"
},
{
"operation_id": "list_remote_sessions_api",
"method": "GET",
"path": "/v1/remote-sessions"
},
{
"operation_id": "stop_remote_session",
"method": "DELETE",
"path": "/v1/remote-sessions/{id}"
},
{
"operation_id": "get_remote_session_api",
"method": "GET",
"path": "/v1/remote-sessions/{id}"
},
{
"operation_id": "remote_session_cdp",
"method": "GET",
"path": "/v1/remote-sessions/{id}/cdp"
},
{
"operation_id": "get_tags",
"method": "GET",
"path": "/v1/tags"
},
{
"operation_id": "get_vpns",
"method": "GET",
"path": "/v1/vpns"
},
{
"operation_id": "create_vpn",
"method": "POST",
"path": "/v1/vpns"
},
{
"operation_id": "import_vpn",
"method": "POST",
"path": "/v1/vpns/import"
},
{
"operation_id": "delete_vpn",
"method": "DELETE",
"path": "/v1/vpns/{id}"
},
{
"operation_id": "get_vpn",
"method": "GET",
"path": "/v1/vpns/{id}"
},
{
"operation_id": "update_vpn",
"method": "PUT",
"path": "/v1/vpns/{id}"
},
{
"operation_id": "export_vpn",
"method": "GET",
"path": "/v1/vpns/{id}/export"
}
]
}
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@donutbrowser/sdk",
"version": "0.1.0",
"description": "Thin client for the Donut Browser local REST API",
"license": "AGPL-3.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"files": [
"dist",
"README.md"
],
"engines": {
"node": ">=22"
},
"scripts": {
"test": "node --test test/*.test.mts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"keywords": [
"donut-browser",
"browser-automation",
"anti-detect",
"cdp"
],
"homepage": "https://donutbrowser.com",
"repository": {
"type": "git",
"url": "git+https://github.com/zhom/donutbrowser.git",
"directory": "sdk/node"
},
"devDependencies": {
"typescript": "^5.9.0"
}
}
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
/**
* 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 `OMITTED` with a reason, and
* - an entry here that the app no longer publishes fails too.
*
* The same table is mirrored in the Python package, and the same snapshot
* proves it.
*/
/** `"<VERB> <path template>"`, exactly as the app publishes it. */
export type OperationKey = string;
/** Operation to the name of the `DonutClient` method that calls it. */
export const OPERATIONS: ReadonlyMap<OperationKey, string> = new Map([
["POST /v1/browsers/download", "downloadBrowser"],
["GET /v1/browsers/{browser}/versions", "listBrowserVersions"],
["GET /v1/browsers/{browser}/versions/{version}/downloaded", "isBrowserDownloaded"],
["GET /v1/cookie-bot/conflicts", "getCookieBotConflicts"],
["GET /v1/cookie-bot/presets", "listCookieBotPresets"],
["GET /v1/cookie-bot/runs", "listCookieBotRuns"],
["POST /v1/cookie-bot/runs", "startCookieBotRun"],
["DELETE /v1/cookie-bot/runs/{run_id}", "cancelCookieBotRun"],
["GET /v1/cookie-bot/schedules", "listCookieBotSchedules"],
["DELETE /v1/cookie-bot/schedules/{profile_id}", "deleteCookieBotSchedule"],
["GET /v1/cookie-bot/schedules/{profile_id}", "getCookieBotSchedule"],
["PUT /v1/cookie-bot/schedules/{profile_id}", "setCookieBotSchedule"],
["GET /v1/cookie-bot/usage", "getCookieBotUsage"],
["GET /v1/extension-groups", "listExtensionGroups"],
["POST /v1/extension-groups", "createExtensionGroup"],
["DELETE /v1/extension-groups/{id}", "deleteExtensionGroup"],
["GET /v1/extension-groups/{id}", "getExtensionGroup"],
["PUT /v1/extension-groups/{id}", "updateExtensionGroup"],
["DELETE /v1/extension-groups/{id}/extensions/{extension_id}", "removeExtensionFromGroup"],
["POST /v1/extension-groups/{id}/extensions/{extension_id}", "addExtensionToGroup"],
["GET /v1/extensions", "listExtensions"],
["POST /v1/extensions", "createExtension"],
["DELETE /v1/extensions/{id}", "deleteExtension"],
["GET /v1/extensions/{id}", "getExtension"],
["PUT /v1/extensions/{id}", "updateExtension"],
["GET /v1/groups", "listGroups"],
["POST /v1/groups", "createGroup"],
["DELETE /v1/groups/{id}", "deleteGroup"],
["GET /v1/groups/{id}", "getGroup"],
["PUT /v1/groups/{id}", "updateGroup"],
["GET /v1/profiles", "listProfiles"],
["POST /v1/profiles", "createProfile"],
["POST /v1/profiles/batch/run", "batchRunProfiles"],
["POST /v1/profiles/batch/stop", "batchStopProfiles"],
["POST /v1/profiles/distribute-proxies", "distributeProxies"],
["POST /v1/profiles/import", "importProfiles"],
["GET /v1/profiles/import/detect", "detectImportProfiles"],
["DELETE /v1/profiles/{id}", "deleteProfile"],
["GET /v1/profiles/{id}", "getProfile"],
["PUT /v1/profiles/{id}", "updateProfile"],
["POST /v1/profiles/{id}/agent/click", "agentClick"],
["POST /v1/profiles/{id}/agent/extract", "agentExtract"],
["POST /v1/profiles/{id}/agent/perceive", "agentPerceive"],
["POST /v1/profiles/{id}/agent/pick", "agentPick"],
["POST /v1/profiles/{id}/agent/resolve-locator", "agentResolveLocator"],
["POST /v1/profiles/{id}/agent/type", "agentType"],
["POST /v1/profiles/{id}/cloud-sync", "setProfileCloudSync"],
["POST /v1/profiles/{id}/cookies/import", "importProfileCookies"],
["POST /v1/profiles/{id}/kill", "killProfile"],
["POST /v1/profiles/{id}/open-url", "openUrl"],
["POST /v1/profiles/{id}/run", "runProfile"],
["POST /v1/profiles/{id}/run-remote", "runProfileRemote"],
["GET /v1/proxies", "listProxies"],
["POST /v1/proxies", "createProxy"],
["POST /v1/proxies/import", "importProxies"],
["DELETE /v1/proxies/{id}", "deleteProxy"],
["GET /v1/proxies/{id}", "getProxy"],
["PUT /v1/proxies/{id}", "updateProxy"],
["GET /v1/remote-hours", "getRemoteHours"],
["GET /v1/remote-sessions", "listRemoteSessions"],
["DELETE /v1/remote-sessions/{id}", "stopRemoteSession"],
["GET /v1/remote-sessions/{id}", "getRemoteSession"],
["GET /v1/tags", "listTags"],
["GET /v1/vpns", "listVpns"],
["POST /v1/vpns", "createVpn"],
["POST /v1/vpns/import", "importVpn"],
["DELETE /v1/vpns/{id}", "deleteVpn"],
["GET /v1/vpns/{id}", "getVpn"],
["PUT /v1/vpns/{id}", "updateVpn"],
["GET /v1/vpns/{id}/export", "exportVpn"],
]);
/** Operations this SDK deliberately does not call, and why. */
export const OMITTED: ReadonlyMap<OperationKey, string> = new Map([
[
"GET /v1/remote-sessions/{id}/cdp",
"A WebSocket upgrade, not a request. fetch() cannot speak it, and bundling a " +
"websocket implementation would end this package's zero-dependency promise for " +
"one endpoint. DonutClient.remoteSessionCdpUrl() builds the ws:// address so a " +
"websocket library of the caller's choosing can connect, sending the same " +
"Authorization: Bearer header on the handshake.",
],
]);
+211
View File
@@ -0,0 +1,211 @@
/**
* Exceptions thrown 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 class and a
* caller can branch on `instanceof` instead of on a number:
*
* | Status | Class | Meaning |
* | -----: | --------------------- | ----------------------------------------- |
* | 400 | `ValidationError` | Malformed request, duplicate name |
* | 401 | `Unauthorized` | Missing or wrong bearer token |
* | 402 | `PaymentRequired` | Automation needs an active paid plan |
* | 403 | `Forbidden` | Terms not accepted, or not signed in |
* | 404 | `NotFound` | No such profile, group, proxy, ... |
* | 408 | `RequestTimeout` | `agent/pick` waited and nothing was picked |
* | 409 | `Conflict` | Something else holds the profile |
* | 429 | `RateLimited` | Quota spent; see `retryAfter` |
* | 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 `null` and `body` holds the
* diagnostic text as sent.
*/
/** Base class for everything this package throws. */
export class DonutError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = new.target.name;
}
}
/**
* 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.
*/
export class DonutConnectionError extends DonutError {}
export interface DonutApiErrorInit {
method?: string;
path?: string;
headers?: Headers | Record<string, string>;
}
/** The app answered, and the answer was an error status. */
export class DonutApiError extends DonutError {
status: number;
body: string;
method: string;
path: string;
headers: Record<string, string>;
/** The `code` of a structured `{"code": ...}` body, else `null`. */
code: string | null;
/** The `params` of a structured body, else an empty object. */
params: Record<string, unknown>;
constructor(status: number, body: string, init: DonutApiErrorInit = {}) {
const method = init.method ?? "";
const path = init.path ?? "";
const headers = normaliseHeaders(init.headers);
let code: string | null = null;
let params: Record<string, unknown> = {};
const trimmed = body.trim();
if (trimmed.startsWith("{")) {
try {
const decoded: unknown = JSON.parse(trimmed);
if (decoded !== null && typeof decoded === "object") {
const record = decoded as Record<string, unknown>;
if (typeof record.code === "string") {
code = record.code;
if (record.params !== null && typeof record.params === "object") {
params = record.params as Record<string, unknown>;
}
}
}
} catch {
// Not JSON after all; the plain text below is the whole story.
}
}
const where = `${method} ${path}`.trim();
const detail = code ?? (trimmed || "(empty body)");
super(where ? `${status} on ${where}: ${detail}` : `${status}: ${detail}`);
this.status = status;
this.body = body;
this.method = method;
this.path = path;
this.headers = headers;
this.code = code;
this.params = params;
}
}
/** 400: the request was malformed, duplicated a name, or named something unsupported. */
export class ValidationError extends DonutApiError {}
/** 401: no bearer token, the wrong one, or the local API has no token stored. */
export class Unauthorized extends DonutApiError {}
/** 402: this action needs an active paid plan, or the proxy behind it lapsed. */
export class PaymentRequired extends DonutApiError {}
/** 403: the Wayfern terms are not accepted, or this desktop is not signed in. */
export class Forbidden extends DonutApiError {}
/** 404: no entity with that id. */
export class NotFound extends DonutApiError {}
/** 408: `agentPick` waited its whole timeout and nothing was picked. */
export class RequestTimeout extends DonutApiError {}
/** 409: something else holds the profile — a browser, a teammate, a remote session. */
export class Conflict extends DonutApiError {}
/**
* 500 and the other 5xx: the app, the fleet or an upstream failed.
*
* `BadGateway` and `ServiceUnavailable` extend this, so one
* `instanceof ServerError` covers every server-side failure.
*/
export class ServerError extends DonutApiError {}
/** 502: the browser or the relay did not answer the way it documents. */
export class BadGateway extends ServerError {}
/**
* 503: Donut cloud, the remote fleet, or the profile lock service is unreachable.
*
* Whatever was running keeps running: a 503 from `killProfile` or from stopping
* a remote session means the browser is still up, not that it stopped.
*/
export class ServiceUnavailable extends ServerError {}
/**
* 429: the shared automation quota is spent.
*
* `retryAfter` is the number of seconds the server asked the caller to wait,
* taken from the `Retry-After` response header. It is `null` only when the
* header is missing or unreadable.
*/
export class RateLimited extends DonutApiError {
retryAfter: number | null;
constructor(status: number, body: string, init: DonutApiErrorInit = {}) {
super(status, body, init);
const raw = this.headers["retry-after"];
const seconds = raw === undefined ? Number.NaN : Number.parseInt(raw.trim(), 10);
this.retryAfter = Number.isFinite(seconds) ? seconds : null;
}
}
function normaliseHeaders(
headers: Headers | Record<string, string> | undefined,
): Record<string, string> {
const result: Record<string, string> = {};
if (headers === undefined) {
return result;
}
if (typeof (headers as Headers).forEach === "function" && !Array.isArray(headers)) {
(headers as Headers).forEach((value, key) => {
result[key.toLowerCase()] = value;
});
return result;
}
for (const [key, value] of Object.entries(headers as Record<string, string>)) {
result[key.toLowerCase()] = value;
}
return result;
}
const BY_STATUS = new Map<number, typeof DonutApiError>([
[400, ValidationError],
[401, Unauthorized],
[402, PaymentRequired],
[403, Forbidden],
[404, NotFound],
[408, RequestTimeout],
[409, Conflict],
[429, RateLimited],
[500, ServerError],
[502, BadGateway],
[503, ServiceUnavailable],
]);
/**
* Build the error that belongs to `status`.
*
* A status with no class of its own becomes a plain `DonutApiError`, so a
* future status added to the app still throws something a caller can catch
* rather than escaping as a decode failure.
*/
export function errorForStatus(
status: number,
body: string,
init: DonutApiErrorInit = {},
): DonutApiError {
const known = BY_STATUS.get(status);
if (known !== undefined) {
return new known(status, body, init);
}
return status >= 500
? new ServerError(status, body, init)
: new DonutApiError(status, body, init);
}
+41
View File
@@ -0,0 +1,41 @@
/**
* 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.
*
* ```ts
* import { DonutClient } from "@donutbrowser/sdk";
*
* const client = new DonutClient({ token: "..." });
* await client.withProfile(profileId, { url: "https://example.com" }, async (session) => {
* console.log(session.cdpUrl);
* await client.agentClick(profileId, { locator: { role: "button", name: "Sign in" } });
* });
* ```
*/
export { DEFAULT_HOST, DEFAULT_PORT, DonutClient, RunSession } from "./client.mts";
export type { DonutClientOptions, RunProfileOptions } from "./client.mts";
export { OMITTED, OPERATIONS } from "./coverage.mts";
export type { OperationKey } from "./coverage.mts";
export {
BadGateway,
Conflict,
DonutApiError,
DonutConnectionError,
DonutError,
errorForStatus,
Forbidden,
NotFound,
PaymentRequired,
RateLimited,
RequestTimeout,
ServerError,
ServiceUnavailable,
Unauthorized,
ValidationError,
} from "./errors.mts";
export type { DonutApiErrorInit } from "./errors.mts";
export type * from "./types.mts";
+634
View File
@@ -0,0 +1,634 @@
/**
* Response shapes, spelled exactly the way the local API sends them.
*
* Every interface here mirrors a `ToSchema` struct in `src-tauri` field for
* field. A Rust `Option<T>` becomes an optional property.
*
* 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. These types follow
* the wire rather than tidying it, so a value read from one call can be passed
* straight into the next.
*/
/** The app's own JSON for a proxy's settings, declared `Object` in the spec. */
export type ProxySettings = Record<string, unknown>;
/** A Wayfern fingerprint/config blob, also declared `Object` in the spec. */
export type WayfernConfig = Record<string, unknown>;
/** Which implementation answered: the browser's native domains, or the fallback. */
export type Engine = "wayfern" | "fallback";
export interface ApiProfile {
id: string;
name: string;
browser: string;
version: string;
proxy_id?: string | null;
launch_hook?: string | null;
process_id?: number | null;
last_launch?: number | null;
release_type: string;
group_id?: string | null;
tags: string[];
is_running: boolean;
proxy_bypass_rules: string[];
vpn_id?: string | null;
extension_group_id?: string | null;
ephemeral: boolean;
temporary: boolean;
clear_on_close: boolean;
/** `"Disabled"`, `"Regular"` or `"Encrypted"`. */
sync_mode: string;
cloud_sync_enabled: boolean;
host_os?: string | null;
/** A profile from another OS can only ever run on a remote host of that OS. */
is_cross_os: boolean;
fingerprint_os?: string | null;
}
export interface ApiProfilesResponse {
profiles: ApiProfile[];
total: number;
}
export interface ApiProfileResponse {
profile: ApiProfile;
}
export interface ApiGroupResponse {
id: string;
name: string;
profile_count: number;
}
export interface ApiProxyResponse {
id: string;
name: string;
proxy_settings: ProxySettings;
}
export interface ApiVpnResponse {
id: string;
name: string;
/** Always `"WireGuard"`. */
vpn_type: string;
created_at: number;
last_used?: number | null;
}
export interface ApiVpnExportResponse {
id: string;
name: string;
vpn_type: string;
/** Raw, decrypted `.conf` content. Treat it as a secret. */
config_data: string;
}
export interface DownloadBrowserResponse {
browser: string;
version: string;
status: string;
}
export interface RunProfileResponse {
profile_id: string;
remote_debugging_port: number;
headless: boolean;
}
export interface RunRemoteResponse {
profile_id: string;
session_id: string;
/** Always the profile's own operating system. */
platform: string;
status: string;
}
export interface StopRemoteResponse {
session_id: string;
status: string;
billed_seconds: number;
}
export interface SetCloudSyncResponse {
profile_id: string;
mode: string;
remote_launchable: boolean;
remote_blocked_reason?: string | null;
}
export interface RemoteSessionState {
session_id: string;
profile_id?: string | null;
platform?: string | null;
/** `provisioning` | `ready` | `live` | `closed` | `error`. */
state: string;
cdp_ready?: boolean;
/** `interactive` or `cookie_bot`. */
kind?: string | null;
run_id?: string | null;
team_id?: string | null;
started_at?: string | null;
ended_at?: string | null;
close_reason?: string | null;
billed_seconds?: number | null;
}
export interface ApiRemoteSessionsResponse {
sessions: RemoteSessionState[];
}
export interface RemoteHoursBreakdown {
interactive_hours?: number;
bot_hours?: number;
}
export interface RemoteHoursMember {
user_id: string;
email: string;
role?: string | null;
used_hours?: number;
interactive_hours?: number;
bot_hours?: number;
}
export interface RemoteHoursQuota {
granted_hours: number;
remaining_hours: number;
used_hours?: number;
period_start?: string | null;
period_end?: string | null;
/** `user` or `team`. */
scope?: string | null;
team_id?: string | null;
seats?: number;
per_seat_hours?: number;
breakdown?: RemoteHoursBreakdown | null;
members?: RemoteHoursMember[];
}
export interface CookieBotSlot {
run_at_minute?: number;
days_mask?: number;
}
export interface CookieBotSchedule {
profile_id: string;
profile_name: string;
platform: string;
enabled: boolean;
run_at_minute: number;
days_mask: number;
/**
* Every time-of-day this enrolment fires. An older server sends only the
* mirrored `run_at_minute`/`days_mask` pair above, so an empty list means
* "fall back to the pair", never "fires at no time".
*/
slots?: CookieBotSlot[];
timezone: string;
preset: string;
template_id?: string | null;
max_minutes: number;
sites?: string[];
jitter_seconds?: number;
sync_enabled?: boolean;
encrypted_sync?: boolean;
has_proxy?: boolean;
proxy_remote_reachable?: boolean;
touch_fingerprint?: boolean;
sticky_exit?: boolean;
profile_state_at?: string | null;
/** Why tonight would be refused, or absent. */
blocked_by?: string | null;
next_run_at?: string | null;
last_run_at?: string | null;
last_run_id?: string | null;
owner_user_id?: string | null;
owner_email?: string | null;
updated_at?: string | null;
}
export interface CookieBotScheduleList {
schedules?: CookieBotSchedule[];
team_id?: string | null;
scope?: string | null;
}
export interface CookieBotConflict {
user_id: string;
email: string;
run_at_minute: number;
timezone: string;
days_mask: number;
enabled: boolean;
overlaps?: boolean;
}
export interface CookieBotScheduleSaved {
schedule: CookieBotSchedule;
conflicts?: CookieBotConflict[];
}
export interface CookieBotConflictCheck {
profile_id: string;
conflicts?: CookieBotConflict[];
}
export interface CookieBotScheduleDeleted {
profile_id: string;
deleted: boolean;
}
export interface CookieBotRun {
id: string;
profile_id: string;
profile_name?: string | null;
user_id?: string | null;
email?: string | null;
team_id?: string | null;
/** `schedule` or `manual`. */
trigger: string;
/** `pending` | `running` | `succeeded` | `partial` | `failed` | `skipped` | `cancelled`. */
status: string;
scheduled_for: string;
dispatch_after?: string | null;
started_at?: string | null;
ended_at?: string | null;
max_minutes?: number;
chunks_total?: number;
chunk_index?: number;
sites_total?: number;
sites_visited?: number;
sites_failed?: number;
consent_dismissed?: number;
billed_seconds?: number;
outcome_code?: string | null;
session_id?: string | null;
}
export interface CookieBotRunPage {
runs?: CookieBotRun[];
/** Keyset cursor; absent on the last page. */
next_before?: string | null;
}
export interface CookieBotRunStarted {
run: CookieBotRun;
session_id?: string | null;
}
export interface CookieBotPreset {
id: string;
typical_minutes?: number | null;
recommended?: boolean;
name?: string | null;
description?: string | null;
}
export interface CookieBotPresetList {
presets?: CookieBotPreset[];
default_preset?: string | null;
/** Whatever the server publishes; the app forwards it without narrowing. */
templates?: Record<string, unknown>[];
limits?: Record<string, unknown> | null;
}
export interface CookieBotUsageMember {
user_id: string;
email: string;
role?: string | null;
interactive_hours?: number;
bot_hours?: number;
used_hours?: number;
sessions?: number;
bot_runs?: number;
bot_runs_failed?: number;
}
export interface CookieBotUsageProfile {
profile_id: string;
profile_name?: string | null;
owner_email?: string | null;
bot_hours?: number;
runs?: number;
runs_failed?: number;
last_run_at?: string | null;
last_status?: string | null;
}
export interface CookieBotUsage {
period: string;
period_start?: string | null;
period_end?: string | null;
team_id?: string | null;
seats?: number;
granted_hours?: number;
used_hours?: number;
remaining_hours?: number;
members?: CookieBotUsageMember[];
profiles?: CookieBotUsageProfile[];
}
export interface BatchRunResult {
profile_id: string;
ok: boolean;
remote_debugging_port?: number | null;
error?: string | null;
}
export interface BatchRunResponse {
results: BatchRunResult[];
}
export interface BatchStopResult {
profile_id: string;
ok: boolean;
error?: string | null;
}
export interface BatchStopResponse {
results: BatchStopResult[];
}
/** One profile, one proxy. The distribution applies exactly these pairs. */
export interface ProxyPair {
profile_id: string;
proxy_id: string;
}
export interface ProxyAssignmentResult {
profile_id: string;
proxy_id: string;
ok: boolean;
/** A `{"code": ...}` payload when `ok` is false, otherwise null. */
error?: string | null;
}
export interface DistributeProxiesResponse {
results: ProxyAssignmentResult[];
}
export interface ImportCookiesResponse {
cookies_imported: number;
cookies_replaced: number;
errors: string[];
}
export interface ImportProxiesResponse {
imported_count: number;
skipped_count: number;
errors: string[];
proxies: ApiProxyResponse[];
}
export interface DetectedProfile {
browser: string;
mapped_browser: string;
name: string;
path: string;
description: string;
}
export interface DetectedProfilesResponse {
profiles: DetectedProfile[];
total: number;
}
export interface ImportProfileItem {
source_path: string;
/**
* The source browser family (`chromium`, `brave`, `edge`, ...). Load-bearing:
* it picks which keychain entry unlocks the source's cookies and passwords.
*/
browser_type?: string;
new_profile_name: string;
proxy_id?: string | null;
vpn_id?: string | null;
allow_running?: boolean | null;
}
export interface ProfileImportItemResult {
name: string;
source_path: string;
/** `"imported"` | `"skipped"` | `"failed"`. */
status: string;
profile_id?: string | null;
error?: string | null;
report?: Record<string, unknown> | null;
}
export interface ProfileImportBatchResult {
imported_count: number;
skipped_count: number;
failed_count: number;
results: ProfileImportItemResult[];
}
export interface Extension {
id: string;
name: string;
manifest_name?: string | null;
file_name: string;
file_type: string;
browser_compatibility: string[];
created_at: number;
updated_at: number;
sync_enabled?: boolean;
last_sync?: number | null;
version?: string | null;
description?: string | null;
author?: string | null;
homepage_url?: string | null;
/** `archive` or `unpacked`. */
source_kind: string;
/** Set when the extension is loaded from a folder in place. Never synced. */
linked_path?: string | null;
}
export interface ExtensionGroup {
id: string;
name: string;
extension_ids: string[];
created_at: number;
updated_at: number;
sync_enabled?: boolean;
last_sync?: number | null;
}
export interface LocatorAttribute {
name: string;
value: string;
}
/**
* How an element is named without a CSS selector.
*
* At least one property 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 `agentPick` uses the spellings below, so reusing one verbatim
* is the reliable path.
*/
export interface LocatorDescription {
/** AX role token, matched case- and separator-insensitively. */
role?: string;
/** Computed accessible name, exact after whitespace collapse. */
name?: string;
nameContains?: string;
/** Visible text content, from the live layout. */
text?: string;
textContains?: string;
attributes?: LocatorAttribute[];
}
export interface LocatorBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface LocatorCandidate {
/** Absent on the fallback engine, which has no DOM agent behind it. */
backendNodeId?: number;
role: string;
name: string;
text: string;
/** Omitted, never blanked, for a control the page marked protected. */
value?: string;
url?: string;
/** Per-profile deterministic identifier for the node's structural position. */
signature: string;
attributes?: LocatorAttribute[];
bounds: LocatorBounds;
}
export interface LocatorResolution {
backendNodeId?: number;
/** Always 1: present so a caller can assert it rather than infer it. */
matchCount: number;
match: LocatorCandidate;
locator: LocatorDescription;
engine: Engine;
}
export interface PerceptionNode {
/** Short, stable, frame-qualified handle. */
id: string;
frameId: string;
role: string;
x: number;
y: number;
width: number;
height: number;
inViewport: boolean;
visible: boolean;
focused: boolean;
disabled: boolean;
parentId?: string;
name?: string;
text?: string;
value?: string;
/** `"true"`, `"false"` or `"mixed"`; absent for anything not checkable. */
checked?: string;
expanded?: boolean;
scrollable?: boolean;
scrollContainerId?: string;
}
export interface PerceptionFrame {
frameId: string;
url: string;
crossOrigin: boolean;
parentFrameId?: string;
}
export interface PerceptionStats {
totalNodes: number;
returnedNodes: number;
bytes: number;
elapsedMs: number;
framesVisited: number;
/** Frames whose renderer did not answer within the budget. */
framesFailed: number;
}
export interface PerceptionPage {
snapshotId: string;
nodes: PerceptionNode[];
frames: PerceptionFrame[];
/** Readable text for exactly the nodes returned. */
text: string;
truncated: boolean;
stats: PerceptionStats;
/** Present when `truncated`: pass it back to continue. */
cursor?: string;
engine: Engine;
}
export interface ExtractionField {
/** The key this column appears under in each row's values. */
key: string;
/** Evaluated inside each container; the first match wins. */
locator: LocatorDescription;
/** `"text"`, `"attribute"` or `"link"`. */
source: string;
/** Required when `source` is `"attribute"`. */
attribute?: string;
}
export interface ExtractionRow {
/** Global across pages. */
index: number;
/** Zero-based page this row came from. */
page: number;
values: Record<string, unknown>;
}
export interface Extraction {
rows: ExtractionRow[];
rowCount: number;
pageCount: number;
byteSize: number;
truncated: boolean;
/**
* `complete` | `no-container` | `no-next` | `page-cap` | `row-cap` |
* `byte-cap` | `time-budget`. A missing container is `no-container`, not an
* error.
*/
stopReason: string;
engine: Engine;
}
export interface PickedElement {
backendNodeId: number;
/** The smallest description that still resolves to this node. */
locator: LocatorDescription;
matchCount: number;
node: LocatorCandidate;
engine: Engine;
}
/** What a click did. Note the snake_case body and the `match` key. */
export interface AgentClick {
clicked: boolean;
match: LocatorCandidate;
engine: Engine;
/** Whether a page load followed the click. */
navigated: boolean;
}
/** What a typing call did. */
export interface AgentTyping {
typed: boolean;
characters: number;
/** Absent on the fallback engine, which does not count its own mistypes. */
corrections?: number;
duration_ms: number;
engine: Engine;
match: LocatorCandidate;
}
+109
View File
@@ -0,0 +1,109 @@
/** Where the token and the port come from, and in what order. */
import assert from "node:assert/strict";
import { test } from "node:test";
import { DEFAULT_HOST, DEFAULT_PORT, DonutClient, DonutError } from "../src/index.mts";
import { FakeDonut } from "./fake-donut.mts";
test("arguments are used as given", () => {
const client = new DonutClient({ token: "from-argument", port: 12345, env: {} });
assert.equal(client.token, "from-argument");
assert.equal(client.port, 12345);
assert.equal(client.host, DEFAULT_HOST);
assert.equal(client.baseUrl, "http://127.0.0.1:12345");
});
test("the environment fills in what was not passed", () => {
const client = new DonutClient({
env: { DONUT_API_TOKEN: "from-env", DONUT_API_PORT: "13579" },
});
assert.equal(client.token, "from-env");
assert.equal(client.port, 13579);
});
test("arguments win over the environment", () => {
const client = new DonutClient({
token: "from-argument",
port: 111,
env: { DONUT_API_TOKEN: "from-env", DONUT_API_PORT: "222" },
});
assert.equal(client.token, "from-argument");
assert.equal(client.port, 111);
});
test("the port falls back to the app default", () => {
const client = new DonutClient({ env: { DONUT_API_TOKEN: "t" } });
assert.equal(client.port, DEFAULT_PORT);
assert.equal(DEFAULT_PORT, 10108);
});
test("a baseUrl overrides host and port", () => {
const client = new DonutClient({
baseUrl: "http://127.0.0.1:9999/donut",
token: "t",
env: { DONUT_API_PORT: "222" },
});
assert.equal(client.port, 9999);
assert.equal(client.baseUrl, "http://127.0.0.1:9999/donut");
});
test("a baseUrl prefix is kept on every path", async () => {
const fake = await new FakeDonut().start();
try {
const client = new DonutClient({
baseUrl: `http://127.0.0.1:${fake.port}/donut`,
token: "t",
timeoutMs: 5_000,
env: {},
});
await client.listProfiles();
assert.equal(fake.last.path, "/donut/v1/profiles");
} finally {
await fake.stop();
}
});
test("an unusable port in the environment is reported", () => {
assert.throws(
() => new DonutClient({ env: { DONUT_API_TOKEN: "t", DONUT_API_PORT: "not-a-number" } }),
/DONUT_API_PORT/,
);
});
test("an unsupported scheme is refused", () => {
assert.throws(
() => new DonutClient({ baseUrl: "ftp://127.0.0.1:9999", token: "t", env: {} }),
DonutError,
);
});
test("the websocket address is built from the same base", () => {
const client = new DonutClient({ token: "t", port: 10108, env: {} });
assert.equal(
client.remoteSessionCdpUrl("s 1"),
"ws://127.0.0.1:10108/v1/remote-sessions/s%201/cdp",
);
});
test("an https base gives a wss websocket address", () => {
const client = new DonutClient({ baseUrl: "https://127.0.0.1:8443", token: "t", env: {} });
assert.equal(
client.remoteSessionCdpUrl("s1"),
"wss://127.0.0.1:8443/v1/remote-sessions/s1/cdp",
);
});
test("a supplied fetch is the one that is used", async () => {
const seen: string[] = [];
const client = new DonutClient({
token: "t",
env: {},
fetch: async (input) => {
seen.push(String(input));
return new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } });
},
});
assert.deepEqual(await client.listTags(), []);
assert.deepEqual(seen, ["http://127.0.0.1:10108/v1/tags"]);
});
+97
View File
@@ -0,0 +1,97 @@
/**
* 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.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { DonutClient, OMITTED, OPERATIONS } from "../src/index.mts";
const SNAPSHOT = fileURLToPath(new URL("../../api-paths.json", import.meta.url));
interface Snapshot {
source: string;
operation_count: number;
operations: { operation_id: string; method: string; path: string }[];
}
function snapshot(): Snapshot {
return JSON.parse(readFileSync(SNAPSHOT, "utf8")) as Snapshot;
}
function published(): Set<string> {
return new Set(snapshot().operations.map((entry) => `${entry.method} ${entry.path}`));
}
test("the snapshot is readable and not empty", () => {
const document = snapshot();
assert.equal(document.source, "src-tauri/src/api_server.rs");
assert.equal(document.operation_count, document.operations.length);
assert.ok(document.operation_count > 0);
assert.equal(
published().size,
document.operation_count,
"the app has two identical operations",
);
});
test("every published operation is wrapped or omitted", () => {
const known = new Set([...OPERATIONS.keys(), ...OMITTED.keys()]);
const missing = [...published()].filter((key) => !known.has(key)).sort();
assert.deepEqual(
missing,
[],
`the app publishes operations this SDK does not handle: ${missing.join(", ")}. ` +
"Wrap each one, or add it to OMITTED with a reason.",
);
});
test("the SDK claims nothing the app does not publish", () => {
const live = published();
const stale = [...OPERATIONS.keys(), ...OMITTED.keys()].filter((key) => !live.has(key)).sort();
assert.deepEqual(
stale,
[],
`this SDK handles operations the app no longer publishes: ${stale.join(", ")}. ` +
"Regenerate the snapshot with sdk/tools/extract-api-paths.py, then drop or fix each entry.",
);
});
test("an operation is either wrapped or omitted but not both", () => {
const both = [...OPERATIONS.keys()].filter((key) => OMITTED.has(key)).sort();
assert.deepEqual(both, [], `listed twice: ${both.join(", ")}`);
});
test("every omission gives a reason", () => {
for (const [operation, reason] of OMITTED) {
assert.ok(reason.trim().length > 40, `${operation} is omitted without a real reason`);
}
});
test("every wrapped operation names a real method", () => {
const prototype = DonutClient.prototype as unknown as Record<string, unknown>;
for (const [operation, name] of OPERATIONS) {
assert.equal(
typeof prototype[name],
"function",
`${operation} names ${name}, which is not a method`,
);
}
});
test("no two operations share a method", () => {
const names = [...OPERATIONS.values()];
const duplicates = [...new Set(names.filter((name, index) => names.indexOf(name) !== index))];
assert.deepEqual(
duplicates,
[],
`one method is claimed by several operations: ${duplicates.join(", ")}`,
);
});
+219
View File
@@ -0,0 +1,219 @@
/** Each status the app documents throws its own error. */
import assert from "node:assert/strict";
import { test } from "node:test";
import {
BadGateway,
Conflict,
DonutApiError,
DonutClient,
DonutConnectionError,
DonutError,
Forbidden,
NotFound,
PaymentRequired,
RateLimited,
RequestTimeout,
ServerError,
ServiceUnavailable,
Unauthorized,
ValidationError,
} from "../src/index.mts";
import { FakeDonut } from "./fake-donut.mts";
import { withClient } from "./support.mts";
const STATUS_TO_ERROR: [number, new (...args: never[]) => DonutApiError][] = [
[400, ValidationError],
[401, Unauthorized],
[402, PaymentRequired],
[403, Forbidden],
[404, NotFound],
[408, RequestTimeout],
[409, Conflict],
[429, RateLimited],
[500, ServerError],
[502, BadGateway],
[503, ServiceUnavailable],
];
for (const [status, expected] of STATUS_TO_ERROR) {
test(`${status} maps to ${expected.name}`, async () => {
await withClient(async (client, fake) => {
fake.enqueueError(status, "something went wrong");
const thrown = await client.listProfiles().then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof expected, `expected ${expected.name}, got ${String(thrown)}`);
assert.equal(thrown.status, status);
assert.equal(thrown.body, "something went wrong");
assert.equal(thrown.method, "GET");
assert.equal(thrown.path, "/v1/profiles");
});
});
}
test("every error is a DonutError", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(404, "PROFILE_NOT_FOUND");
await assert.rejects(client.getProfile("nope"), DonutError);
});
});
test("the five hundreds share one base", async () => {
await withClient(async (client, fake) => {
for (const status of [500, 502, 503]) {
fake.enqueueError(status, "upstream");
await assert.rejects(client.listProfiles(), ServerError);
}
});
});
test("rate limited carries retryAfter", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(429, "automation request rate limit exceeded", { "Retry-After": "42" });
const thrown = await client.runProfile("p1").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof RateLimited);
assert.equal(thrown.retryAfter, 42);
});
});
test("rate limited without the header is still thrown", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(429, "slow down");
const thrown = await client.runProfile("p1").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof RateLimited);
assert.equal(thrown.retryAfter, null);
});
});
test("an unreadable Retry-After does not break the error", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(429, "slow down", { "Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT" });
const thrown = await client.runProfile("p1").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof RateLimited);
assert.equal(thrown.retryAfter, null);
});
});
test("a structured code body is decoded", async () => {
// The app shares `{"code": ...}` strings with its own frontend.
await withClient(async (client, fake) => {
fake.enqueueError(400, JSON.stringify({ code: "NAME_CANNOT_BE_EMPTY" }));
const thrown = await client.createGroup("").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof ValidationError);
assert.equal(thrown.code, "NAME_CANNOT_BE_EMPTY");
assert.deepEqual(thrown.params, {});
});
});
test("a structured code body keeps its params", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(
409,
JSON.stringify({ code: "PROFILE_LOCKED_BY_MEMBER", params: { n: "5" } }),
);
const thrown = await client.runProfile("p1").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof Conflict);
assert.equal(thrown.code, "PROFILE_LOCKED_BY_MEMBER");
assert.deepEqual(thrown.params, { n: "5" });
});
});
test("a plain text body leaves code unset", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(400, "invalid browser");
const thrown = await client.createProfile({ name: "x", browser: "chromium" }).then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof ValidationError);
assert.equal(thrown.code, null);
assert.equal(thrown.body, "invalid browser");
});
});
test("an undocumented status still throws something catchable", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(418, "teapot");
const thrown = await client.listProfiles().then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof DonutApiError);
assert.equal(thrown.status, 418);
});
});
test("an undocumented server status is a ServerError", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(504, "gateway timeout");
await assert.rejects(client.listProfiles(), ServerError);
});
});
test("the message names the call", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(404, "Profile not found");
const thrown = await client.getProfile("missing").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof NotFound);
assert.match(thrown.message, /404/);
assert.match(thrown.message, /GET \/v1\/profiles\/missing/);
});
});
test("errors keep their class name", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(404, "gone");
const thrown = await client.listProfiles().then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof NotFound);
assert.equal(thrown.name, "NotFound");
});
});
test("an unreachable app is not an API error", async () => {
const fake = await new FakeDonut().start();
const port = fake.port;
await fake.stop();
const client = new DonutClient({ token: "t", port, timeoutMs: 2_000, env: {} });
const thrown = await client.listProfiles().then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof DonutConnectionError);
assert.match(thrown.message, /Local API/);
});
test("a missing token fails before any request", () => {
assert.throws(() => new DonutClient({ env: {} }), /DONUT_API_TOKEN/);
});
test("a non-JSON answer is reported as such", async () => {
await withClient(async (client, fake) => {
fake.enqueueRaw(200, "<html>nope</html>");
await assert.rejects(client.listProfiles(), /not\s+JSON/);
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* 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.
*/
import { createServer } from "node:http";
import type { IncomingMessage, Server, ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
export interface RecordedRequest {
method: string;
target: string;
path: string;
query: Record<string, string>;
headers: Record<string, string>;
rawBody: string;
json: unknown;
}
export interface QueuedResponse {
status: number;
body: string;
headers: Record<string, string>;
contentType: string;
}
export class FakeDonut {
requests: RecordedRequest[] = [];
responses: QueuedResponse[] = [];
#server: Server | undefined = undefined;
enqueueJson(payload: unknown, status = 200): void {
this.responses.push({
status,
body: JSON.stringify(payload),
headers: {},
contentType: "application/json",
});
}
enqueueEmpty(status = 204): void {
this.responses.push({ status, body: "", headers: {}, contentType: "application/json" });
}
enqueueError(status: number, body = "", headers: Record<string, string> = {}): void {
this.responses.push({ status, body, headers, contentType: "text/plain" });
}
enqueueRaw(status: number, body: string, contentType = "text/html"): void {
this.responses.push({ status, body, headers: {}, contentType });
}
get port(): number {
if (this.#server === undefined) {
throw new Error("the fake server is not running");
}
return (this.#server.address() as AddressInfo).port;
}
get last(): RecordedRequest {
const request = this.requests.at(-1);
if (request === undefined) {
throw new Error("the client sent nothing");
}
return request;
}
async start(): Promise<this> {
const server = createServer((incoming: IncomingMessage, outgoing: ServerResponse) => {
const chunks: Buffer[] = [];
incoming.on("data", (chunk: Buffer) => chunks.push(chunk));
incoming.on("end", () => {
const rawBody = Buffer.concat(chunks).toString("utf8");
const url = new URL(incoming.url ?? "/", "http://127.0.0.1");
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(incoming.headers)) {
headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : (value ?? "");
}
this.requests.push({
method: incoming.method ?? "",
target: incoming.url ?? "",
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
headers,
rawBody,
json: rawBody === "" ? null : JSON.parse(rawBody),
});
const queued = this.responses.shift() ?? {
status: 200,
body: "{}",
headers: {},
contentType: "application/json",
};
for (const [name, value] of Object.entries(queued.headers)) {
outgoing.setHeader(name, value);
}
if (queued.body !== "") {
outgoing.setHeader("Content-Type", queued.contentType);
}
outgoing.writeHead(queued.status);
outgoing.end(queued.body);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
this.#server = server;
return this;
}
async stop(): Promise<void> {
const server = this.#server;
if (server === undefined) {
return;
}
this.#server = undefined;
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
}
/** Start a fake server, hand it to `work`, and always shut it down again. */
export async function withFakeDonut<T>(work: (fake: FakeDonut) => Promise<T>): Promise<T> {
const fake = await new FakeDonut().start();
try {
return await work(fake);
} finally {
await fake.stop();
}
}
+768
View File
@@ -0,0 +1,768 @@
/**
* 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
* `OPERATIONS` and, through it, to `sdk/api-paths.json`.
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import { OPERATIONS } from "../src/index.mts";
import { withClient } from "./support.mts";
interface Case {
method: string;
args: unknown[];
verb: string;
path: string;
body: unknown;
query?: Record<string, string>;
operation: string;
}
const LOCATOR = { role: "button", name: "Sign in" };
const CASES: Case[] = [
// -- profiles ------------------------------------------------------------
{
method: "listProfiles",
args: [],
verb: "GET",
path: "/v1/profiles",
body: null,
operation: "GET /v1/profiles",
},
{
method: "getProfile",
args: ["p1"],
verb: "GET",
path: "/v1/profiles/p1",
body: null,
operation: "GET /v1/profiles/{id}",
},
{
method: "createProfile",
args: [{ name: "Shopper", browser: "wayfern", tags: ["eu"], ephemeral: true }],
verb: "POST",
path: "/v1/profiles",
body: { name: "Shopper", browser: "wayfern", tags: ["eu"], ephemeral: true },
operation: "POST /v1/profiles",
},
{
method: "createProfile",
args: [{ name: "Bare", browser: "wayfern", version: undefined }],
verb: "POST",
path: "/v1/profiles",
body: { name: "Bare", browser: "wayfern" },
operation: "POST /v1/profiles",
},
{
method: "updateProfile",
args: ["p1", { name: "Renamed", proxy_id: "", clear_on_close: false }],
verb: "PUT",
path: "/v1/profiles/p1",
body: { name: "Renamed", proxy_id: "", clear_on_close: false },
operation: "PUT /v1/profiles/{id}",
},
{
method: "deleteProfile",
args: ["p1"],
verb: "DELETE",
path: "/v1/profiles/p1",
body: null,
operation: "DELETE /v1/profiles/{id}",
},
{
method: "runProfile",
args: ["p1", { url: "https://example.com", headless: true }],
verb: "POST",
path: "/v1/profiles/p1/run",
body: { url: "https://example.com", headless: true },
operation: "POST /v1/profiles/{id}/run",
},
{
method: "runProfileRemote",
args: ["p1", { url: "https://example.com" }],
verb: "POST",
path: "/v1/profiles/p1/run-remote",
body: { url: "https://example.com" },
operation: "POST /v1/profiles/{id}/run-remote",
},
{
method: "setProfileCloudSync",
args: ["p1", "Regular"],
verb: "POST",
path: "/v1/profiles/p1/cloud-sync",
body: { mode: "Regular" },
operation: "POST /v1/profiles/{id}/cloud-sync",
},
{
method: "openUrl",
args: ["p1", "https://example.com/page"],
verb: "POST",
path: "/v1/profiles/p1/open-url",
body: { url: "https://example.com/page" },
operation: "POST /v1/profiles/{id}/open-url",
},
{
method: "killProfile",
args: ["p1"],
verb: "POST",
path: "/v1/profiles/p1/kill",
body: null,
operation: "POST /v1/profiles/{id}/kill",
},
{
method: "batchRunProfiles",
args: [["p1", "p2"], { headless: false }],
verb: "POST",
path: "/v1/profiles/batch/run",
body: { profile_ids: ["p1", "p2"], headless: false },
operation: "POST /v1/profiles/batch/run",
},
{
method: "batchStopProfiles",
args: [["p1", "p2"]],
verb: "POST",
path: "/v1/profiles/batch/stop",
body: { profile_ids: ["p1", "p2"] },
operation: "POST /v1/profiles/batch/stop",
},
{
method: "distributeProxies",
args: [
[
{ profile_id: "p1", proxy_id: "x1" },
{ profile_id: "p2", proxy_id: "x2" },
],
],
verb: "POST",
path: "/v1/profiles/distribute-proxies",
body: {
pairs: [
{ profile_id: "p1", proxy_id: "x1" },
{ profile_id: "p2", proxy_id: "x2" },
],
},
operation: "POST /v1/profiles/distribute-proxies",
},
{
method: "detectImportProfiles",
args: [{ folder: "/Users/x/Chrome" }],
verb: "GET",
path: "/v1/profiles/import/detect",
body: null,
query: { folder: "/Users/x/Chrome" },
operation: "GET /v1/profiles/import/detect",
},
{
method: "detectImportProfiles",
args: [],
verb: "GET",
path: "/v1/profiles/import/detect",
body: null,
operation: "GET /v1/profiles/import/detect",
},
{
method: "importProfiles",
args: [
[{ source_path: "/tmp/src", new_profile_name: "Imported" }],
{ duplicate_strategy: "skip" },
],
verb: "POST",
path: "/v1/profiles/import",
body: {
items: [{ source_path: "/tmp/src", new_profile_name: "Imported" }],
duplicate_strategy: "skip",
},
operation: "POST /v1/profiles/import",
},
{
method: "importProfileCookies",
args: ["p1", "[]"],
verb: "POST",
path: "/v1/profiles/p1/cookies/import",
body: { content: "[]" },
operation: "POST /v1/profiles/{id}/cookies/import",
},
// -- agent ---------------------------------------------------------------
{
method: "agentPerceive",
args: ["p1", { viewport_only: true, max_bytes: 2048 }],
verb: "POST",
path: "/v1/profiles/p1/agent/perceive",
body: { viewport_only: true, max_bytes: 2048 },
operation: "POST /v1/profiles/{id}/agent/perceive",
},
{
method: "agentPerceive",
args: ["p1"],
verb: "POST",
path: "/v1/profiles/p1/agent/perceive",
body: {},
operation: "POST /v1/profiles/{id}/agent/perceive",
},
{
method: "agentResolveLocator",
args: ["p1", { locator: LOCATOR, candidate_limit: 5 }],
verb: "POST",
path: "/v1/profiles/p1/agent/resolve-locator",
body: { locator: LOCATOR, candidate_limit: 5 },
operation: "POST /v1/profiles/{id}/agent/resolve-locator",
},
{
method: "agentClick",
args: ["p1", { locator: LOCATOR, button: "right", click_count: 2 }],
verb: "POST",
path: "/v1/profiles/p1/agent/click",
body: { locator: LOCATOR, button: "right", click_count: 2 },
operation: "POST /v1/profiles/{id}/agent/click",
},
{
method: "agentType",
args: ["p1", { locator: LOCATOR, text: "hello", clear_first: false, wpm: 55 }],
verb: "POST",
path: "/v1/profiles/p1/agent/type",
body: { locator: LOCATOR, text: "hello", clear_first: false, wpm: 55 },
operation: "POST /v1/profiles/{id}/agent/type",
},
{
method: "agentExtract",
args: [
"p1",
{
container: { role: "listitem" },
field_map: [{ key: "title", locator: { role: "heading" }, source: "text" }],
max_pages: 3,
},
],
verb: "POST",
path: "/v1/profiles/p1/agent/extract",
body: {
container: { role: "listitem" },
field_map: [{ key: "title", locator: { role: "heading" }, source: "text" }],
max_pages: 3,
},
operation: "POST /v1/profiles/{id}/agent/extract",
},
{
method: "agentPick",
args: ["p1", { timeout_ms: 15000 }],
verb: "POST",
path: "/v1/profiles/p1/agent/pick",
body: { timeout_ms: 15000 },
operation: "POST /v1/profiles/{id}/agent/pick",
},
// -- remote sessions -----------------------------------------------------
{
method: "listRemoteSessions",
args: [],
verb: "GET",
path: "/v1/remote-sessions",
body: null,
operation: "GET /v1/remote-sessions",
},
{
method: "getRemoteSession",
args: ["s1"],
verb: "GET",
path: "/v1/remote-sessions/s1",
body: null,
operation: "GET /v1/remote-sessions/{id}",
},
{
method: "stopRemoteSession",
args: ["s1"],
verb: "DELETE",
path: "/v1/remote-sessions/s1",
body: null,
operation: "DELETE /v1/remote-sessions/{id}",
},
{
method: "getRemoteHours",
args: [],
verb: "GET",
path: "/v1/remote-hours",
body: null,
operation: "GET /v1/remote-hours",
},
// -- cookie bot ----------------------------------------------------------
{
method: "listCookieBotSchedules",
args: [{ scope: "team" }],
verb: "GET",
path: "/v1/cookie-bot/schedules",
body: null,
query: { scope: "team" },
operation: "GET /v1/cookie-bot/schedules",
},
{
method: "getCookieBotSchedule",
args: ["p1"],
verb: "GET",
path: "/v1/cookie-bot/schedules/p1",
body: null,
operation: "GET /v1/cookie-bot/schedules/{profile_id}",
},
{
method: "setCookieBotSchedule",
args: [
"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,
},
],
verb: "PUT",
path: "/v1/cookie-bot/schedules/p1",
body: {
enabled: true,
run_at_minute: 120,
days_mask: 31,
timezone: "Europe/Berlin",
preset: "steady",
max_minutes: 45,
sites: ["https://example.com"],
acknowledge_conflict: true,
},
operation: "PUT /v1/cookie-bot/schedules/{profile_id}",
},
{
method: "deleteCookieBotSchedule",
args: ["p1"],
verb: "DELETE",
path: "/v1/cookie-bot/schedules/p1",
body: null,
operation: "DELETE /v1/cookie-bot/schedules/{profile_id}",
},
{
method: "getCookieBotConflicts",
args: ["p1", { run_at_minute: 90, timezone: "UTC", days_mask: 7 }],
verb: "GET",
path: "/v1/cookie-bot/conflicts",
body: null,
query: { profile_id: "p1", run_at_minute: "90", timezone: "UTC", days_mask: "7" },
operation: "GET /v1/cookie-bot/conflicts",
},
{
method: "listCookieBotRuns",
args: [{ profile_id: "p1", limit: 10, before: "cursor-1" }],
verb: "GET",
path: "/v1/cookie-bot/runs",
body: null,
query: { profile_id: "p1", limit: "10", before: "cursor-1" },
operation: "GET /v1/cookie-bot/runs",
},
{
method: "startCookieBotRun",
args: [{ profile_id: "p1", max_minutes: 30 }],
verb: "POST",
path: "/v1/cookie-bot/runs",
body: { profile_id: "p1", max_minutes: 30 },
operation: "POST /v1/cookie-bot/runs",
},
{
method: "cancelCookieBotRun",
args: ["r1"],
verb: "DELETE",
path: "/v1/cookie-bot/runs/r1",
body: null,
operation: "DELETE /v1/cookie-bot/runs/{run_id}",
},
{
method: "listCookieBotPresets",
args: [],
verb: "GET",
path: "/v1/cookie-bot/presets",
body: null,
operation: "GET /v1/cookie-bot/presets",
},
{
method: "getCookieBotUsage",
args: [{ period: "2026-08" }],
verb: "GET",
path: "/v1/cookie-bot/usage",
body: null,
query: { period: "2026-08" },
operation: "GET /v1/cookie-bot/usage",
},
// -- groups and tags -----------------------------------------------------
{
method: "listGroups",
args: [],
verb: "GET",
path: "/v1/groups",
body: null,
operation: "GET /v1/groups",
},
{
method: "getGroup",
args: ["g1"],
verb: "GET",
path: "/v1/groups/g1",
body: null,
operation: "GET /v1/groups/{id}",
},
{
method: "createGroup",
args: ["Retail"],
verb: "POST",
path: "/v1/groups",
body: { name: "Retail" },
operation: "POST /v1/groups",
},
{
method: "updateGroup",
args: ["g1", "Retail EU"],
verb: "PUT",
path: "/v1/groups/g1",
body: { name: "Retail EU" },
operation: "PUT /v1/groups/{id}",
},
{
method: "deleteGroup",
args: ["g1"],
verb: "DELETE",
path: "/v1/groups/g1",
body: null,
operation: "DELETE /v1/groups/{id}",
},
{
method: "listTags",
args: [],
verb: "GET",
path: "/v1/tags",
body: null,
operation: "GET /v1/tags",
},
// -- proxies -------------------------------------------------------------
{
method: "listProxies",
args: [],
verb: "GET",
path: "/v1/proxies",
body: null,
operation: "GET /v1/proxies",
},
{
method: "getProxy",
args: ["x1"],
verb: "GET",
path: "/v1/proxies/x1",
body: null,
operation: "GET /v1/proxies/{id}",
},
{
method: "createProxy",
args: [{ name: "EU", proxy_settings: { proxy_type: "http", host: "h", port: 8080 } }],
verb: "POST",
path: "/v1/proxies",
body: { name: "EU", proxy_settings: { proxy_type: "http", host: "h", port: 8080 } },
operation: "POST /v1/proxies",
},
{
method: "updateProxy",
args: ["x1", { name: "EU 2" }],
verb: "PUT",
path: "/v1/proxies/x1",
body: { name: "EU 2" },
operation: "PUT /v1/proxies/{id}",
},
{
method: "deleteProxy",
args: ["x1"],
verb: "DELETE",
path: "/v1/proxies/x1",
body: null,
operation: "DELETE /v1/proxies/{id}",
},
{
method: "importProxies",
args: [{ format: "txt", content: "h:1:u:p", name_prefix: "EU" }],
verb: "POST",
path: "/v1/proxies/import",
body: { format: "txt", content: "h:1:u:p", name_prefix: "EU" },
operation: "POST /v1/proxies/import",
},
// -- vpns ----------------------------------------------------------------
{
method: "listVpns",
args: [],
verb: "GET",
path: "/v1/vpns",
body: null,
operation: "GET /v1/vpns",
},
{
method: "getVpn",
args: ["v1"],
verb: "GET",
path: "/v1/vpns/v1",
body: null,
operation: "GET /v1/vpns/{id}",
},
{
method: "exportVpn",
args: ["v1"],
verb: "GET",
path: "/v1/vpns/v1/export",
body: null,
operation: "GET /v1/vpns/{id}/export",
},
{
method: "importVpn",
args: [{ content: "[Interface]", filename: "eu.conf" }],
verb: "POST",
path: "/v1/vpns/import",
body: { content: "[Interface]", filename: "eu.conf" },
operation: "POST /v1/vpns/import",
},
{
method: "createVpn",
args: [{ name: "EU", vpn_type: "WireGuard", config_data: "[Interface]" }],
verb: "POST",
path: "/v1/vpns",
body: { name: "EU", vpn_type: "WireGuard", config_data: "[Interface]" },
operation: "POST /v1/vpns",
},
{
method: "updateVpn",
args: ["v1", "EU 2"],
verb: "PUT",
path: "/v1/vpns/v1",
body: { name: "EU 2" },
operation: "PUT /v1/vpns/{id}",
},
{
method: "deleteVpn",
args: ["v1"],
verb: "DELETE",
path: "/v1/vpns/v1",
body: null,
operation: "DELETE /v1/vpns/{id}",
},
// -- extensions ----------------------------------------------------------
{
method: "listExtensions",
args: [],
verb: "GET",
path: "/v1/extensions",
body: null,
operation: "GET /v1/extensions",
},
{
method: "getExtension",
args: ["e1"],
verb: "GET",
path: "/v1/extensions/e1",
body: null,
operation: "GET /v1/extensions/{id}",
},
{
method: "createExtension",
args: [{ name: "Blocker", file_name: "b.crx", file_data_base64: "AAAA" }],
verb: "POST",
path: "/v1/extensions",
body: { name: "Blocker", file_name: "b.crx", file_data_base64: "AAAA" },
operation: "POST /v1/extensions",
},
{
method: "updateExtension",
args: ["e1", { name: "Blocker 2", link: true }],
verb: "PUT",
path: "/v1/extensions/e1",
body: { name: "Blocker 2", link: true },
operation: "PUT /v1/extensions/{id}",
},
{
method: "deleteExtension",
args: ["e1"],
verb: "DELETE",
path: "/v1/extensions/e1",
body: null,
operation: "DELETE /v1/extensions/{id}",
},
{
method: "listExtensionGroups",
args: [],
verb: "GET",
path: "/v1/extension-groups",
body: null,
operation: "GET /v1/extension-groups",
},
{
method: "getExtensionGroup",
args: ["eg1"],
verb: "GET",
path: "/v1/extension-groups/eg1",
body: null,
operation: "GET /v1/extension-groups/{id}",
},
{
method: "createExtensionGroup",
args: ["Adblock set"],
verb: "POST",
path: "/v1/extension-groups",
body: { name: "Adblock set" },
operation: "POST /v1/extension-groups",
},
{
method: "updateExtensionGroup",
args: ["eg1", { extension_ids: ["e1", "e2"] }],
verb: "PUT",
path: "/v1/extension-groups/eg1",
body: { extension_ids: ["e1", "e2"] },
operation: "PUT /v1/extension-groups/{id}",
},
{
method: "deleteExtensionGroup",
args: ["eg1"],
verb: "DELETE",
path: "/v1/extension-groups/eg1",
body: null,
operation: "DELETE /v1/extension-groups/{id}",
},
{
method: "addExtensionToGroup",
args: ["eg1", "e1"],
verb: "POST",
path: "/v1/extension-groups/eg1/extensions/e1",
body: null,
operation: "POST /v1/extension-groups/{id}/extensions/{extension_id}",
},
{
method: "removeExtensionFromGroup",
args: ["eg1", "e1"],
verb: "DELETE",
path: "/v1/extension-groups/eg1/extensions/e1",
body: null,
operation: "DELETE /v1/extension-groups/{id}/extensions/{extension_id}",
},
// -- browsers ------------------------------------------------------------
{
method: "downloadBrowser",
args: [{ browser: "wayfern", version: "152.0.1" }],
verb: "POST",
path: "/v1/browsers/download",
body: { browser: "wayfern", version: "152.0.1" },
operation: "POST /v1/browsers/download",
},
{
method: "listBrowserVersions",
args: ["wayfern"],
verb: "GET",
path: "/v1/browsers/wayfern/versions",
body: null,
operation: "GET /v1/browsers/{browser}/versions",
},
{
method: "isBrowserDownloaded",
args: ["wayfern", "152.0.1"],
verb: "GET",
path: "/v1/browsers/wayfern/versions/152.0.1/downloaded",
body: null,
operation: "GET /v1/browsers/{browser}/versions/{version}/downloaded",
},
];
for (const [index, expected] of CASES.entries()) {
test(`${expected.method} sends the documented request [${index}]`, async () => {
await withClient(async (client, fake) => {
const callable = (client as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>)[
expected.method
];
assert.equal(typeof callable, "function", `${expected.method} is not a method`);
await callable.call(client, ...expected.args);
const sent = fake.last;
assert.equal(sent.method, expected.verb);
assert.equal(sent.path, expected.path);
assert.deepEqual(sent.query, expected.query ?? {});
assert.deepEqual(sent.json, expected.body);
assert.equal(OPERATIONS.get(expected.operation), expected.method);
});
});
}
test("every wrapped operation has a request test", () => {
const covered = new Set(CASES.map((entry) => entry.method));
const missing = [...OPERATIONS.values()].filter((name) => !covered.has(name)).sort();
assert.deepEqual(missing, [], `these wrapped operations have no request test: ${missing}`);
});
test("the token travels as a bearer header", async () => {
await withClient(async (client, fake) => {
await client.listProfiles();
assert.equal(fake.last.headers.authorization, "Bearer test-token-abc123");
assert.equal(fake.last.headers.accept, "application/json");
assert.equal(
fake.last.headers["content-type"],
undefined,
"a GET must not claim to carry JSON",
);
});
});
test("a body is sent as JSON", async () => {
await withClient(async (client, fake) => {
await client.createGroup("Retail");
assert.equal(fake.last.headers["content-type"], "application/json");
assert.equal(fake.last.rawBody, '{"name":"Retail"}');
});
});
test("path ids are escaped", async () => {
await withClient(async (client, fake) => {
await client.getProfile("a/b c?d");
assert.equal(fake.last.path, "/v1/profiles/a%2Fb%20c%3Fd");
});
});
test("undefined arguments are left out of the body", async () => {
await withClient(async (client, fake) => {
await client.updateProfile("p1", { name: "Only this", version: undefined });
assert.deepEqual(fake.last.json, { name: "Only this" });
});
});
test("an empty string still reaches the app", async () => {
// `proxy_id: ""` is how the app is told to detach a proxy, so it must survive.
await withClient(async (client, fake) => {
await client.updateProfile("p1", { proxy_id: "" });
assert.deepEqual(fake.last.json, { proxy_id: "" });
});
});
test("a no-content answer becomes undefined", async () => {
await withClient(async (client, fake) => {
fake.enqueueEmpty(204);
assert.equal(await client.deleteProfile("p1"), undefined);
});
});
test("a JSON answer is returned as sent", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson({ profiles: [{ id: "p1", name: "Shopper" }], total: 1 });
assert.deepEqual(await client.listProfiles(), {
profiles: [{ id: "p1", name: "Shopper" }],
total: 1,
});
});
});
test("a bare boolean answer is returned", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(true);
assert.equal(await client.isBrowserDownloaded("wayfern", "152.0.1"), true);
});
});
+134
View File
@@ -0,0 +1,134 @@
/** `withProfile` launches, hands over the CDP endpoint, and stops. */
import assert from "node:assert/strict";
import { test } from "node:test";
import { Conflict, DonutError, RunSession } from "../src/index.mts";
import { withClient } from "./support.mts";
const RUN_BODY = { profile_id: "p1", remote_debugging_port: 9222, headless: true };
test("the callback gets the CDP endpoint", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueEmpty(204);
const seen = await client.withProfile(
"p1",
{ url: "https://example.com", headless: true },
(session) => {
assert.ok(session instanceof RunSession);
assert.equal(session.remoteDebuggingPort, 9222);
assert.equal(session.headless, true);
assert.equal(session.cdpUrl, "http://127.0.0.1:9222");
assert.deepEqual(session.response, RUN_BODY);
return session.cdpUrl;
},
);
assert.equal(seen, "http://127.0.0.1:9222");
assert.deepEqual(
fake.requests.map((sent) => `${sent.method} ${sent.path}`),
["POST /v1/profiles/p1/run", "POST /v1/profiles/p1/kill"],
);
assert.deepEqual(fake.requests[0]?.json, { url: "https://example.com", headless: true });
});
});
test("the browser is stopped when the callback throws", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueEmpty(204);
await assert.rejects(
client.withProfile("p1", {}, () => {
throw new RangeError("the body failed");
}),
RangeError,
);
assert.deepEqual(
fake.requests.map((sent) => sent.path),
["/v1/profiles/p1/run", "/v1/profiles/p1/kill"],
);
});
});
test("a failed stop never hides why the callback failed", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueError(409, "PROFILE_LOCKED_ELSEWHERE");
let captured: RunSession | undefined;
await assert.rejects(
client.withProfile("p1", {}, (session) => {
captured = session;
throw new RangeError("the body failed");
}),
RangeError,
);
assert.ok(captured?.cleanupError instanceof Conflict);
});
});
test("a failed stop is thrown when the callback was fine", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueError(503, "the fleet could not be reached");
await assert.rejects(
client.withProfile("p1", {}, () => "done"),
DonutError,
);
});
});
test("a failed launch never runs the callback and stops nothing", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(409, "PROFILE_RUNNING");
await assert.rejects(
client.withProfile("p1", {}, () => {
throw new Error("the callback must not run when the launch failed");
}),
Conflict,
);
assert.deepEqual(
fake.requests.map((sent) => sent.path),
["/v1/profiles/p1/run"],
);
});
});
test("an async callback is awaited before the browser is stopped", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueJson({ profiles: [], total: 0 });
fake.enqueueEmpty(204);
await client.withProfile("p1", {}, async () => {
await client.listProfiles();
});
assert.deepEqual(
fake.requests.map((sent) => sent.path),
["/v1/profiles/p1/run", "/v1/profiles", "/v1/profiles/p1/kill"],
);
});
});
test("a session also disposes itself", async () => {
// `withProfile` is the portable form, but a runtime with `await using` can
// hold a RunSession directly.
await withClient(async (client, fake) => {
fake.enqueueEmpty(204);
const session = new RunSession(client, "p1", RUN_BODY);
await session[Symbol.asyncDispose]();
assert.deepEqual(
fake.requests.map((sent) => sent.path),
["/v1/profiles/p1/kill"],
);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { DonutClient } from "../src/index.mts";
import { FakeDonut } from "./fake-donut.mts";
export const TOKEN = "test-token-abc123";
/** Start a fake app, point a client at it, and always shut the server down. */
export async function withClient<T>(
work: (client: DonutClient, fake: FakeDonut) => Promise<T>,
): Promise<T> {
const fake = await new FakeDonut().start();
try {
const client = new DonutClient({
token: TOKEN,
port: fake.port,
timeoutMs: 5_000,
env: {},
});
return await work(client, fake);
} finally {
await fake.stop();
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM", "ESNext.Disposable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"exactOptionalPropertyTypes": false,
"noUncheckedIndexedAccess": true,
"declaration": true,
"noEmitOnError": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": false,
"outDir": "dist",
"rootDir": "src",
"types": [],
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src/**/*.mts"]
}
+37
View File
@@ -0,0 +1,37 @@
# donutbrowser
A thin Python client for the [Donut Browser](https://donutbrowser.com) local
REST API. Every method wraps exactly one documented endpoint; nothing is
invented, cached or retried.
The local API is off by default. Switch it on in the app under **Settings →
Integrations → Local API → "Enable Local API Server"**, then copy the port and
the authentication token from that screen.
```bash
pip install -e . # from this directory
```
```python
from donutbrowser import DonutClient
with DonutClient(token="...") as client:
with client.run(profile_id, url="https://example.com", headless=True) as session:
print(session.cdp_url)
```
The client reads `DONUT_API_TOKEN` and `DONUT_API_PORT` when the token and port
are not passed as arguments.
Full documentation, including the Node package and a worked agent example, is in
[`sdk/README.md`](../README.md).
## Tests
```bash
pip install -e ".[dev]"
pytest
```
The suite runs entirely against a fake HTTP server on loopback. It never reaches
the network and never needs the desktop app.
+40
View File
@@ -0,0 +1,40 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "donutbrowser"
version = "0.1.0"
description = "Thin client for the Donut Browser local REST API"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "AGPL-3.0" }
keywords = ["donut-browser", "browser-automation", "anti-detect", "cdp"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Internet :: WWW/HTTP",
"Typing :: Typed",
]
# No runtime dependencies on purpose: this client talks to a loopback server on
# the same machine, so the standard library is enough and installing the SDK can
# never drag a transitive dependency into an automation environment.
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=7"]
[project.urls]
Homepage = "https://donutbrowser.com"
Source = "https://github.com/zhom/donutbrowser"
[tool.hatch.build.targets.wheel]
packages = ["src/donutbrowser"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+59
View File
@@ -0,0 +1,59 @@
"""Donut Browser SDK: a thin client for the app's local REST API.
The local API is off by default. Switch it on in the app under **Settings,
Integrations, Local API, "Enable Local API Server"**, and copy the port and the
authentication token from that screen.
::
from donutbrowser import DonutClient
with DonutClient(token="...") as client:
with client.run(profile_id, url="https://example.com") as session:
client.agent_click(profile_id, locator={"role": "button", "name": "Sign in"})
"""
from .client import DEFAULT_HOST, DEFAULT_PORT, DonutClient, RunSession
from .coverage import OMITTED, OPERATIONS
from .errors import (
BadGateway,
Conflict,
DonutAPIError,
DonutConnectionError,
DonutError,
Forbidden,
NotFound,
PaymentRequired,
RateLimited,
RequestTimeout,
ServerError,
ServiceUnavailable,
Unauthorized,
ValidationError,
)
__version__ = "0.1.0"
__all__ = [
"DonutClient",
"RunSession",
"DEFAULT_HOST",
"DEFAULT_PORT",
"OPERATIONS",
"OMITTED",
"DonutError",
"DonutConnectionError",
"DonutAPIError",
"ValidationError",
"Unauthorized",
"PaymentRequired",
"Forbidden",
"NotFound",
"RequestTimeout",
"Conflict",
"RateLimited",
"ServerError",
"BadGateway",
"ServiceUnavailable",
"__version__",
]
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
"""Which app operation each client method wraps.
This table is the SDK's half of a two-sided check. ``sdk/api-paths.json`` holds
every operation the desktop app publishes, generated from
``src-tauri/src/api_server.rs``. The test suite asserts the two agree exactly in
both directions, so:
* an endpoint added to the app fails the SDK tests until it is wrapped here, or
listed in :data:`OMITTED` with a reason, and
* an entry here that the app no longer publishes fails too.
The same table is mirrored in the Node package, and the same snapshot proves it.
"""
from __future__ import annotations
from typing import Dict, Tuple
__all__ = ["OPERATIONS", "OMITTED"]
Operation = Tuple[str, str]
#: ``(method, path template)`` to the name of the :class:`~donutbrowser.DonutClient`
#: method that calls it.
OPERATIONS: Dict[Operation, str] = {
("POST", "/v1/browsers/download"): "download_browser",
("GET", "/v1/browsers/{browser}/versions"): "list_browser_versions",
("GET", "/v1/browsers/{browser}/versions/{version}/downloaded"): "is_browser_downloaded",
("GET", "/v1/cookie-bot/conflicts"): "get_cookie_bot_conflicts",
("GET", "/v1/cookie-bot/presets"): "list_cookie_bot_presets",
("GET", "/v1/cookie-bot/runs"): "list_cookie_bot_runs",
("POST", "/v1/cookie-bot/runs"): "start_cookie_bot_run",
("DELETE", "/v1/cookie-bot/runs/{run_id}"): "cancel_cookie_bot_run",
("GET", "/v1/cookie-bot/schedules"): "list_cookie_bot_schedules",
("DELETE", "/v1/cookie-bot/schedules/{profile_id}"): "delete_cookie_bot_schedule",
("GET", "/v1/cookie-bot/schedules/{profile_id}"): "get_cookie_bot_schedule",
("PUT", "/v1/cookie-bot/schedules/{profile_id}"): "set_cookie_bot_schedule",
("GET", "/v1/cookie-bot/usage"): "get_cookie_bot_usage",
("GET", "/v1/extension-groups"): "list_extension_groups",
("POST", "/v1/extension-groups"): "create_extension_group",
("DELETE", "/v1/extension-groups/{id}"): "delete_extension_group",
("GET", "/v1/extension-groups/{id}"): "get_extension_group",
("PUT", "/v1/extension-groups/{id}"): "update_extension_group",
(
"DELETE",
"/v1/extension-groups/{id}/extensions/{extension_id}",
): "remove_extension_from_group",
("POST", "/v1/extension-groups/{id}/extensions/{extension_id}"): "add_extension_to_group",
("GET", "/v1/extensions"): "list_extensions",
("POST", "/v1/extensions"): "create_extension",
("DELETE", "/v1/extensions/{id}"): "delete_extension",
("GET", "/v1/extensions/{id}"): "get_extension",
("PUT", "/v1/extensions/{id}"): "update_extension",
("GET", "/v1/groups"): "list_groups",
("POST", "/v1/groups"): "create_group",
("DELETE", "/v1/groups/{id}"): "delete_group",
("GET", "/v1/groups/{id}"): "get_group",
("PUT", "/v1/groups/{id}"): "update_group",
("GET", "/v1/profiles"): "list_profiles",
("POST", "/v1/profiles"): "create_profile",
("POST", "/v1/profiles/batch/run"): "batch_run_profiles",
("POST", "/v1/profiles/batch/stop"): "batch_stop_profiles",
("POST", "/v1/profiles/distribute-proxies"): "distribute_proxies",
("POST", "/v1/profiles/import"): "import_profiles",
("GET", "/v1/profiles/import/detect"): "detect_import_profiles",
("DELETE", "/v1/profiles/{id}"): "delete_profile",
("GET", "/v1/profiles/{id}"): "get_profile",
("PUT", "/v1/profiles/{id}"): "update_profile",
("POST", "/v1/profiles/{id}/agent/click"): "agent_click",
("POST", "/v1/profiles/{id}/agent/extract"): "agent_extract",
("POST", "/v1/profiles/{id}/agent/perceive"): "agent_perceive",
("POST", "/v1/profiles/{id}/agent/pick"): "agent_pick",
("POST", "/v1/profiles/{id}/agent/resolve-locator"): "agent_resolve_locator",
("POST", "/v1/profiles/{id}/agent/type"): "agent_type",
("POST", "/v1/profiles/{id}/cloud-sync"): "set_profile_cloud_sync",
("POST", "/v1/profiles/{id}/cookies/import"): "import_profile_cookies",
("POST", "/v1/profiles/{id}/kill"): "kill_profile",
("POST", "/v1/profiles/{id}/open-url"): "open_url",
("POST", "/v1/profiles/{id}/run"): "run_profile",
("POST", "/v1/profiles/{id}/run-remote"): "run_profile_remote",
("GET", "/v1/proxies"): "list_proxies",
("POST", "/v1/proxies"): "create_proxy",
("POST", "/v1/proxies/import"): "import_proxies",
("DELETE", "/v1/proxies/{id}"): "delete_proxy",
("GET", "/v1/proxies/{id}"): "get_proxy",
("PUT", "/v1/proxies/{id}"): "update_proxy",
("GET", "/v1/remote-hours"): "get_remote_hours",
("GET", "/v1/remote-sessions"): "list_remote_sessions",
("DELETE", "/v1/remote-sessions/{id}"): "stop_remote_session",
("GET", "/v1/remote-sessions/{id}"): "get_remote_session",
("GET", "/v1/tags"): "list_tags",
("GET", "/v1/vpns"): "list_vpns",
("POST", "/v1/vpns"): "create_vpn",
("POST", "/v1/vpns/import"): "import_vpn",
("DELETE", "/v1/vpns/{id}"): "delete_vpn",
("GET", "/v1/vpns/{id}"): "get_vpn",
("PUT", "/v1/vpns/{id}"): "update_vpn",
("GET", "/v1/vpns/{id}/export"): "export_vpn",
}
#: Operations this SDK deliberately does not call, and why.
OMITTED: Dict[Operation, str] = {
(
"GET",
"/v1/remote-sessions/{id}/cdp",
): (
"A WebSocket upgrade, not a request. An HTTP client cannot speak it, and "
"bundling a websocket implementation would end this package's zero-dependency "
"promise for one endpoint. DonutClient.remote_session_cdp_url() builds the "
"ws:// address so a websocket library of the caller's choosing can connect, "
"sending the same Authorization: Bearer header on the handshake."
),
}
+240
View File
@@ -0,0 +1,240 @@
"""Exceptions raised by the Donut Browser SDK.
The local REST API answers with a plain-text body and one of a small set of
statuses. Each status means one thing, so each gets its own exception and a
caller can branch on the class instead of on a number:
=== ========================== ==================================
403 ``Forbidden`` Terms not accepted, or not signed in
400 ``ValidationError`` Malformed request, duplicate name
401 ``Unauthorized`` Missing or wrong bearer token
402 ``PaymentRequired`` Automation needs an active paid plan
404 ``NotFound`` No such profile, group, proxy, ...
408 ``RequestTimeout`` ``agent/pick`` waited and nothing was picked
409 ``Conflict`` Something else holds the profile right now
429 ``RateLimited`` Automation quota spent; see ``retry_after``
500 ``ServerError`` Internal failure
502 ``BadGateway`` The browser or relay answered wrongly
503 ``ServiceUnavailable`` Cloud, fleet or lock service unreachable
=== ========================== ==================================
Some bodies are the structured ``{"code": ..., "params": {...}}`` strings the
desktop app shares with its own frontend. When one arrives, ``code`` and
``params`` are filled in; otherwise ``code`` is ``None`` and ``body`` holds the
diagnostic text as sent.
"""
from __future__ import annotations
import json
from typing import Any, Mapping, Optional
__all__ = [
"DonutError",
"DonutConnectionError",
"DonutAPIError",
"ValidationError",
"Unauthorized",
"PaymentRequired",
"Forbidden",
"NotFound",
"RequestTimeout",
"Conflict",
"RateLimited",
"ServerError",
"BadGateway",
"ServiceUnavailable",
"error_for_status",
]
class DonutError(Exception):
"""Base class for everything this package raises."""
class DonutConnectionError(DonutError):
"""The app could not be reached at all.
Usually means the local API is switched off, is listening on another port,
or the desktop app is not running.
"""
class DonutAPIError(DonutError):
"""The app answered, and the answer was an error status."""
#: HTTP status this class is raised for. ``None`` on the base class, which
#: catches every status without a more specific subclass.
status: Optional[int] = None
def __init__(
self,
status: int,
body: str,
*,
method: str = "",
path: str = "",
headers: Optional[Mapping[str, str]] = None,
) -> None:
self.status = status
self.body = body
self.method = method
self.path = path
self.headers = dict(headers or {})
self.code: Optional[str] = None
self.params: dict[str, Any] = {}
stripped = body.strip()
if stripped.startswith("{"):
try:
decoded = json.loads(stripped)
except ValueError:
decoded = None
if isinstance(decoded, dict) and isinstance(decoded.get("code"), str):
self.code = decoded["code"]
params = decoded.get("params")
if isinstance(params, dict):
self.params = params
where = f"{method} {path}".strip()
detail = self.code or stripped or "(empty body)"
super().__init__(f"{status} on {where}: {detail}" if where else f"{status}: {detail}")
class ValidationError(DonutAPIError):
"""400: the request was malformed, duplicated a name, or named something unsupported."""
status = 400
class Unauthorized(DonutAPIError):
"""401: no bearer token, the wrong one, or the local API has no token stored."""
status = 401
class PaymentRequired(DonutAPIError):
"""402: this action needs an active paid plan, or the proxy behind it lapsed."""
status = 402
class Forbidden(DonutAPIError):
"""403: the Wayfern terms are not accepted, or this desktop is not signed in."""
status = 403
class NotFound(DonutAPIError):
"""404: no entity with that id."""
status = 404
class RequestTimeout(DonutAPIError):
"""408: ``agent/pick`` waited its whole timeout and nothing was picked."""
status = 408
class Conflict(DonutAPIError):
"""409: something else holds the profile — a browser, a teammate, a remote session."""
status = 409
class RateLimited(DonutAPIError):
"""429: the shared automation quota is spent.
``retry_after`` is the number of seconds the server asked the caller to
wait, taken from the ``Retry-After`` response header. It is ``None`` only
when the header is missing or unreadable.
"""
status = 429
def __init__(
self,
status: int,
body: str,
*,
method: str = "",
path: str = "",
headers: Optional[Mapping[str, str]] = None,
) -> None:
super().__init__(status, body, method=method, path=path, headers=headers)
self.retry_after: Optional[int] = None
raw = next(
(value for key, value in self.headers.items() if key.lower() == "retry-after"),
None,
)
if raw is not None:
try:
self.retry_after = int(str(raw).strip())
except ValueError:
self.retry_after = None
class ServerError(DonutAPIError):
"""500 and the other 5xx: the app, the fleet or an upstream failed.
``BadGateway`` and ``ServiceUnavailable`` derive from this, so one
``except ServerError`` catches every server-side failure.
"""
status = 500
class BadGateway(ServerError):
"""502: the browser or the relay did not answer the way it documents."""
status = 502
class ServiceUnavailable(ServerError):
"""503: Donut cloud, the remote fleet, or the profile lock service is unreachable.
Whatever was running keeps running: a 503 from ``kill`` or from stopping a
remote session means the browser is still up, not that it stopped.
"""
status = 503
_BY_STATUS: dict[int, type[DonutAPIError]] = {
cls.status: cls
for cls in (
ValidationError,
Unauthorized,
PaymentRequired,
Forbidden,
NotFound,
RequestTimeout,
Conflict,
RateLimited,
ServerError,
BadGateway,
ServiceUnavailable,
)
if cls.status is not None
}
def error_for_status(
status: int,
body: str,
*,
method: str = "",
path: str = "",
headers: Optional[Mapping[str, str]] = None,
) -> DonutAPIError:
"""Build the exception that belongs to ``status``.
A status with no class of its own becomes a plain :class:`DonutAPIError`,
so a future status added to the app still raises something a caller can
catch rather than escaping as a decode failure.
"""
cls = _BY_STATUS.get(status)
if cls is None:
cls = ServerError if status >= 500 else DonutAPIError
return cls(status, body, method=method, path=path, headers=headers)
+763
View File
@@ -0,0 +1,763 @@
"""Response shapes, spelled exactly the way the local API sends them.
Every entry here mirrors a ``ToSchema`` struct in ``src-tauri`` field for field.
A Rust ``Option<T>`` becomes a key that may be absent, expressed with the
``total=False`` half of each pair of classes, so ``dict.get`` is the honest way
to read one.
Two spellings live side by side because the app sends both. Most bodies are
snake_case; the browser-facing agent types (``LocatorDescription``,
``LocatorCandidate``, ``PerceptionPage`` and friends) carry the browser's own
camelCase, because they are handed through from the browser rather than
restated. ``AgentClick`` and ``AgentTyping`` are the exceptions inside the
agent surface: they are snake_case with a single ``match`` key. The types below
follow the wire rather than tidying it, so a value read from one call can be
passed straight into the next.
"""
from __future__ import annotations
from typing import Any, Dict, List, TypedDict
__all__ = [
"ApiProfile",
"ApiProfilesResponse",
"ApiProfileResponse",
"ApiGroupResponse",
"ApiProxyResponse",
"ApiVpnResponse",
"ApiVpnExportResponse",
"DownloadBrowserResponse",
"RunProfileResponse",
"RunRemoteResponse",
"StopRemoteResponse",
"SetCloudSyncResponse",
"RemoteSessionState",
"ApiRemoteSessionsResponse",
"RemoteHoursBreakdown",
"RemoteHoursMember",
"RemoteHoursQuota",
"CookieBotSlot",
"CookieBotSchedule",
"CookieBotScheduleList",
"CookieBotConflict",
"CookieBotScheduleSaved",
"CookieBotConflictCheck",
"CookieBotScheduleDeleted",
"CookieBotRun",
"CookieBotRunPage",
"CookieBotRunStarted",
"CookieBotPreset",
"CookieBotPresetList",
"CookieBotUsageMember",
"CookieBotUsageProfile",
"CookieBotUsage",
"BatchRunResult",
"BatchRunResponse",
"BatchStopResult",
"BatchStopResponse",
"ProxyPair",
"ProxyAssignmentResult",
"DistributeProxiesResponse",
"ImportCookiesResponse",
"ImportProxiesResponse",
"DetectedProfile",
"DetectedProfilesResponse",
"ImportProfileItem",
"ProfileImportItemResult",
"ProfileImportBatchResult",
"Extension",
"ExtensionGroup",
"LocatorAttribute",
"LocatorDescription",
"LocatorBounds",
"LocatorCandidate",
"LocatorResolution",
"PerceptionNode",
"PerceptionFrame",
"PerceptionStats",
"PerceptionPage",
"ExtractionField",
"ExtractionRow",
"Extraction",
"PickedElement",
"AgentClick",
"AgentTyping",
]
# The app's own JSON for a proxy's settings. Declared `Object` in the OpenAPI
# document rather than a struct, so it is passed through untouched.
ProxySettings = Dict[str, Any]
# A Wayfern fingerprint/config blob. Also declared `Object` in the document.
WayfernConfig = Dict[str, Any]
class _ApiProfileRequired(TypedDict):
id: str
name: str
browser: str
version: str
release_type: str
tags: List[str]
is_running: bool
proxy_bypass_rules: List[str]
ephemeral: bool
temporary: bool
clear_on_close: bool
sync_mode: str
cloud_sync_enabled: bool
is_cross_os: bool
class ApiProfile(_ApiProfileRequired, total=False):
proxy_id: str
launch_hook: str
process_id: int
last_launch: int
group_id: str
vpn_id: str
extension_group_id: str
host_os: str
fingerprint_os: str
class ApiProfilesResponse(TypedDict):
profiles: List[ApiProfile]
total: int
class ApiProfileResponse(TypedDict):
profile: ApiProfile
class ApiGroupResponse(TypedDict):
id: str
name: str
profile_count: int
class ApiProxyResponse(TypedDict):
id: str
name: str
proxy_settings: ProxySettings
class _ApiVpnRequired(TypedDict):
id: str
name: str
vpn_type: str
created_at: int
class ApiVpnResponse(_ApiVpnRequired, total=False):
last_used: int
class ApiVpnExportResponse(TypedDict):
id: str
name: str
vpn_type: str
config_data: str
class DownloadBrowserResponse(TypedDict):
browser: str
version: str
status: str
class RunProfileResponse(TypedDict):
profile_id: str
remote_debugging_port: int
headless: bool
class RunRemoteResponse(TypedDict):
profile_id: str
session_id: str
platform: str
status: str
class StopRemoteResponse(TypedDict):
session_id: str
status: str
billed_seconds: int
class _SetCloudSyncRequired(TypedDict):
profile_id: str
mode: str
remote_launchable: bool
class SetCloudSyncResponse(_SetCloudSyncRequired, total=False):
remote_blocked_reason: str
class _RemoteSessionStateRequired(TypedDict):
session_id: str
state: str
class RemoteSessionState(_RemoteSessionStateRequired, total=False):
profile_id: str
platform: str
cdp_ready: bool
kind: str
run_id: str
team_id: str
started_at: str
ended_at: str
close_reason: str
billed_seconds: int
class ApiRemoteSessionsResponse(TypedDict):
sessions: List[RemoteSessionState]
class RemoteHoursBreakdown(TypedDict, total=False):
interactive_hours: float
bot_hours: float
class _RemoteHoursMemberRequired(TypedDict):
user_id: str
email: str
class RemoteHoursMember(_RemoteHoursMemberRequired, total=False):
role: str
used_hours: float
interactive_hours: float
bot_hours: float
class _RemoteHoursQuotaRequired(TypedDict):
granted_hours: float
remaining_hours: float
class RemoteHoursQuota(_RemoteHoursQuotaRequired, total=False):
used_hours: float
period_start: str
period_end: str
scope: str
team_id: str
seats: int
per_seat_hours: float
breakdown: RemoteHoursBreakdown
members: List[RemoteHoursMember]
class CookieBotSlot(TypedDict, total=False):
run_at_minute: int
days_mask: int
class _CookieBotScheduleRequired(TypedDict):
profile_id: str
profile_name: str
platform: str
enabled: bool
run_at_minute: int
days_mask: int
timezone: str
preset: str
max_minutes: int
class CookieBotSchedule(_CookieBotScheduleRequired, total=False):
slots: List[CookieBotSlot]
template_id: str
sites: List[str]
jitter_seconds: int
sync_enabled: bool
encrypted_sync: bool
has_proxy: bool
proxy_remote_reachable: bool
touch_fingerprint: bool
sticky_exit: bool
profile_state_at: str
blocked_by: str
next_run_at: str
last_run_at: str
last_run_id: str
owner_user_id: str
owner_email: str
updated_at: str
class CookieBotScheduleList(TypedDict, total=False):
schedules: List[CookieBotSchedule]
team_id: str
scope: str
class _CookieBotConflictRequired(TypedDict):
user_id: str
email: str
run_at_minute: int
timezone: str
days_mask: int
enabled: bool
class CookieBotConflict(_CookieBotConflictRequired, total=False):
overlaps: bool
class _CookieBotScheduleSavedRequired(TypedDict):
schedule: CookieBotSchedule
class CookieBotScheduleSaved(_CookieBotScheduleSavedRequired, total=False):
conflicts: List[CookieBotConflict]
class _CookieBotConflictCheckRequired(TypedDict):
profile_id: str
class CookieBotConflictCheck(_CookieBotConflictCheckRequired, total=False):
conflicts: List[CookieBotConflict]
class CookieBotScheduleDeleted(TypedDict):
profile_id: str
deleted: bool
class _CookieBotRunRequired(TypedDict):
id: str
profile_id: str
trigger: str
status: str
scheduled_for: str
class CookieBotRun(_CookieBotRunRequired, total=False):
profile_name: str
user_id: str
email: str
team_id: str
dispatch_after: str
started_at: str
ended_at: str
max_minutes: int
chunks_total: int
chunk_index: int
sites_total: int
sites_visited: int
sites_failed: int
consent_dismissed: int
billed_seconds: int
outcome_code: str
session_id: str
class CookieBotRunPage(TypedDict, total=False):
runs: List[CookieBotRun]
next_before: str
class _CookieBotRunStartedRequired(TypedDict):
run: CookieBotRun
class CookieBotRunStarted(_CookieBotRunStartedRequired, total=False):
session_id: str
class _CookieBotPresetRequired(TypedDict):
id: str
class CookieBotPreset(_CookieBotPresetRequired, total=False):
typical_minutes: int
recommended: bool
name: str
description: str
class CookieBotPresetList(TypedDict, total=False):
presets: List[CookieBotPreset]
default_preset: str
# `templates` and `limits` are whatever the server publishes; the app
# forwards them without narrowing, so neither is spelled out here.
templates: List[Dict[str, Any]]
limits: Dict[str, Any]
class _CookieBotUsageMemberRequired(TypedDict):
user_id: str
email: str
class CookieBotUsageMember(_CookieBotUsageMemberRequired, total=False):
role: str
interactive_hours: float
bot_hours: float
used_hours: float
sessions: int
bot_runs: int
bot_runs_failed: int
class _CookieBotUsageProfileRequired(TypedDict):
profile_id: str
class CookieBotUsageProfile(_CookieBotUsageProfileRequired, total=False):
profile_name: str
owner_email: str
bot_hours: float
runs: int
runs_failed: int
last_run_at: str
last_status: str
class _CookieBotUsageRequired(TypedDict):
period: str
class CookieBotUsage(_CookieBotUsageRequired, total=False):
period_start: str
period_end: str
team_id: str
seats: int
granted_hours: float
used_hours: float
remaining_hours: float
members: List[CookieBotUsageMember]
profiles: List[CookieBotUsageProfile]
class _BatchRunResultRequired(TypedDict):
profile_id: str
ok: bool
class BatchRunResult(_BatchRunResultRequired, total=False):
remote_debugging_port: int
error: str
class BatchRunResponse(TypedDict):
results: List[BatchRunResult]
class _BatchStopResultRequired(TypedDict):
profile_id: str
ok: bool
class BatchStopResult(_BatchStopResultRequired, total=False):
error: str
class BatchStopResponse(TypedDict):
results: List[BatchStopResult]
class _ProxyAssignmentResultRequired(TypedDict):
profile_id: str
proxy_id: str
ok: bool
class ProxyAssignmentResult(_ProxyAssignmentResultRequired, total=False):
"""``error`` is a ``{"code": ...}`` payload when ``ok`` is false."""
error: str
class ProxyPair(TypedDict):
"""One profile, one proxy. The distribution applies exactly these pairs."""
profile_id: str
proxy_id: str
class DistributeProxiesResponse(TypedDict):
results: List[ProxyAssignmentResult]
class ImportCookiesResponse(TypedDict):
cookies_imported: int
cookies_replaced: int
errors: List[str]
class ImportProxiesResponse(TypedDict):
imported_count: int
skipped_count: int
errors: List[str]
proxies: List[ApiProxyResponse]
class DetectedProfile(TypedDict):
browser: str
mapped_browser: str
name: str
path: str
description: str
class DetectedProfilesResponse(TypedDict):
profiles: List[DetectedProfile]
total: int
class _ImportProfileItemRequired(TypedDict):
source_path: str
new_profile_name: str
class ImportProfileItem(_ImportProfileItemRequired, total=False):
"""One item of ``import_profiles``.
``browser_type`` defaults to the app's own default when absent, and it is
load-bearing: it picks which keychain entry unlocks the source's cookies
and passwords.
"""
browser_type: str
proxy_id: str
vpn_id: str
allow_running: bool
class _ProfileImportItemResultRequired(TypedDict):
name: str
source_path: str
status: str
class ProfileImportItemResult(_ProfileImportItemResultRequired, total=False):
profile_id: str
error: str
report: Dict[str, Any]
class ProfileImportBatchResult(TypedDict):
imported_count: int
skipped_count: int
failed_count: int
results: List[ProfileImportItemResult]
class _ExtensionRequired(TypedDict):
id: str
name: str
file_name: str
file_type: str
browser_compatibility: List[str]
created_at: int
updated_at: int
source_kind: str
class Extension(_ExtensionRequired, total=False):
manifest_name: str
sync_enabled: bool
last_sync: int
version: str
description: str
author: str
homepage_url: str
linked_path: str
class _ExtensionGroupRequired(TypedDict):
id: str
name: str
extension_ids: List[str]
created_at: int
updated_at: int
class ExtensionGroup(_ExtensionGroupRequired, total=False):
sync_enabled: bool
last_sync: int
class LocatorAttribute(TypedDict):
name: str
value: str
class LocatorDescription(TypedDict, total=False):
"""How an element is named without a CSS selector.
At least one key must be set. Keys are the browser's own camelCase; the
app also accepts ``name_contains`` and ``text_contains`` on input, but a
locator handed back by ``agent_pick`` uses the spellings below, so reusing
one verbatim is the reliable path.
"""
role: str
name: str
nameContains: str
text: str
textContains: str
attributes: List[LocatorAttribute]
class LocatorBounds(TypedDict):
x: float
y: float
width: float
height: float
class _LocatorCandidateRequired(TypedDict):
role: str
name: str
text: str
signature: str
bounds: LocatorBounds
class LocatorCandidate(_LocatorCandidateRequired, total=False):
backendNodeId: int
value: str
url: str
attributes: List[LocatorAttribute]
class _LocatorResolutionRequired(TypedDict):
matchCount: int
# `match` is the key the app sends. It is a soft keyword in Python, so it
# is spelled here exactly as it arrives.
match: LocatorCandidate
locator: LocatorDescription
engine: str
class LocatorResolution(_LocatorResolutionRequired, total=False):
backendNodeId: int
class _PerceptionNodeRequired(TypedDict):
id: str
frameId: str
role: str
x: float
y: float
width: float
height: float
inViewport: bool
visible: bool
focused: bool
disabled: bool
class PerceptionNode(_PerceptionNodeRequired, total=False):
parentId: str
name: str
text: str
value: str
checked: str
expanded: bool
scrollable: bool
scrollContainerId: str
class _PerceptionFrameRequired(TypedDict):
frameId: str
url: str
crossOrigin: bool
class PerceptionFrame(_PerceptionFrameRequired, total=False):
parentFrameId: str
class PerceptionStats(TypedDict):
totalNodes: int
returnedNodes: int
bytes: int
elapsedMs: int
framesVisited: int
framesFailed: int
class _PerceptionPageRequired(TypedDict):
snapshotId: str
nodes: List[PerceptionNode]
frames: List[PerceptionFrame]
text: str
truncated: bool
stats: PerceptionStats
engine: str
class PerceptionPage(_PerceptionPageRequired, total=False):
cursor: str
class _ExtractionFieldRequired(TypedDict):
key: str
locator: LocatorDescription
source: str
class ExtractionField(_ExtractionFieldRequired, total=False):
"""One output column. ``attribute`` is required when ``source`` is ``"attribute"``."""
attribute: str
class ExtractionRow(TypedDict):
index: int
page: int
values: Dict[str, Any]
class Extraction(TypedDict):
rows: List[ExtractionRow]
rowCount: int
pageCount: int
byteSize: int
truncated: bool
stopReason: str
engine: str
class PickedElement(TypedDict):
backendNodeId: int
locator: LocatorDescription
matchCount: int
node: LocatorCandidate
engine: str
class AgentClick(TypedDict):
"""What a click did. Note the snake_case body and the ``match`` key."""
clicked: bool
match: LocatorCandidate
engine: str
navigated: bool
class _AgentTypingRequired(TypedDict):
typed: bool
characters: int
duration_ms: float
engine: str
match: LocatorCandidate
class AgentTyping(_AgentTypingRequired, total=False):
"""What a typing call did.
``corrections`` is absent on the fallback engine, which does not count its
own mistypes.
"""
corrections: int
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Iterator
import pytest
# Run against the working tree without an install step, so `pytest` works
# straight after a checkout.
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from donutbrowser import DonutClient # noqa: E402
from fake_donut import FakeDonut # noqa: E402
TOKEN = "test-token-abc123"
@pytest.fixture
def fake() -> Iterator[FakeDonut]:
server = FakeDonut().start()
try:
yield server
finally:
server.stop()
@pytest.fixture
def client(fake: FakeDonut) -> Iterator[DonutClient]:
with DonutClient(token=TOKEN, port=fake.port, timeout=5.0, env={}) as connected:
yield connected
+144
View File
@@ -0,0 +1,144 @@
"""A stand-in for the desktop app's local REST API.
It records what the client sent, byte for byte, and answers with whatever the
test queued. Nothing here reaches the network: it binds an ephemeral loopback
port and is torn down with the test.
"""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, urlsplit
@dataclass
class RecordedRequest:
method: str
target: str
headers: Dict[str, str]
body: bytes
@property
def path(self) -> str:
return urlsplit(self.target).path
@property
def query(self) -> Dict[str, str]:
return dict(parse_qsl(urlsplit(self.target).query, keep_blank_values=True))
@property
def json(self) -> Any:
if not self.body:
return None
return json.loads(self.body.decode("utf-8"))
def header(self, name: str) -> Optional[str]:
for key, value in self.headers.items():
if key.lower() == name.lower():
return value
return None
@dataclass
class QueuedResponse:
status: int = 200
body: str = ""
headers: Tuple[Tuple[str, str], ...] = ()
content_type: str = "application/json"
@dataclass
class FakeDonut:
"""Queue responses, then read :attr:`requests` back."""
requests: List[RecordedRequest] = field(default_factory=list)
responses: List[QueuedResponse] = field(default_factory=list)
_server: Optional[ThreadingHTTPServer] = None
_thread: Optional[threading.Thread] = None
def enqueue_json(self, payload: Any, status: int = 200) -> None:
self.responses.append(QueuedResponse(status=status, body=json.dumps(payload)))
def enqueue_empty(self, status: int = 204) -> None:
self.responses.append(QueuedResponse(status=status, body=""))
def enqueue_error(
self,
status: int,
body: str = "",
headers: Tuple[Tuple[str, str], ...] = (),
) -> None:
self.responses.append(
QueuedResponse(status=status, body=body, headers=headers, content_type="text/plain")
)
@property
def port(self) -> int:
assert self._server is not None, "the fake server is not running"
return self._server.server_address[1]
@property
def last(self) -> RecordedRequest:
assert self.requests, "the client sent nothing"
return self.requests[-1]
def start(self) -> "FakeDonut":
fake = self
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_args: Any) -> None:
"""Keep the test output clean."""
def _handle(self) -> None:
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b""
fake.requests.append(
RecordedRequest(
method=self.command,
target=self.path,
headers={key: value for key, value in self.headers.items()},
body=body,
)
)
queued = fake.responses.pop(0) if fake.responses else QueuedResponse(body="{}")
payload = queued.body.encode("utf-8")
self.send_response(queued.status)
for name, value in queued.headers:
self.send_header(name, value)
if payload:
self.send_header("Content-Type", queued.content_type)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
if payload:
self.wfile.write(payload)
do_GET = _handle
do_POST = _handle
do_PUT = _handle
do_DELETE = _handle
do_PATCH = _handle
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
# A short poll interval so `shutdown()` returns promptly: the default
# 0.5s would add half a second to the teardown of every single test.
self._thread = threading.Thread(
target=self._server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True
)
self._thread.start()
return self
def stop(self) -> None:
if self._server is not None:
self._server.shutdown()
self._server.server_close()
self._server = None
if self._thread is not None:
self._thread.join(timeout=5)
self._thread = None
+83
View File
@@ -0,0 +1,83 @@
"""Where the token and the port come from, and in what order."""
from __future__ import annotations
import pytest
from fake_donut import FakeDonut
from donutbrowser import DEFAULT_HOST, DEFAULT_PORT, DonutClient, DonutError
def test_arguments_are_used_as_given() -> None:
client = DonutClient(token="from-argument", port=12345, env={})
assert client.token == "from-argument"
assert client.port == 12345
assert client.host == DEFAULT_HOST
assert client.base_url == "http://127.0.0.1:12345"
def test_the_environment_fills_in_what_was_not_passed() -> None:
client = DonutClient(env={"DONUT_API_TOKEN": "from-env", "DONUT_API_PORT": "13579"})
assert client.token == "from-env"
assert client.port == 13579
def test_arguments_win_over_the_environment() -> None:
client = DonutClient(
token="from-argument",
port=111,
env={"DONUT_API_TOKEN": "from-env", "DONUT_API_PORT": "222"},
)
assert client.token == "from-argument"
assert client.port == 111
def test_the_port_falls_back_to_the_app_default() -> None:
client = DonutClient(env={"DONUT_API_TOKEN": "t"})
assert client.port == DEFAULT_PORT == 10108
def test_a_base_url_overrides_host_and_port() -> None:
client = DonutClient(
base_url="http://127.0.0.1:9999/donut",
token="t",
env={"DONUT_API_PORT": "222"},
)
assert client.port == 9999
assert client.base_url == "http://127.0.0.1:9999/donut"
def test_a_base_url_prefix_is_kept_on_every_path(fake: FakeDonut) -> None:
with DonutClient(
base_url=f"http://127.0.0.1:{fake.port}/donut", token="t", timeout=5.0, env={}
) as client:
client.list_profiles()
assert fake.last.path == "/donut/v1/profiles"
def test_an_unusable_port_in_the_environment_is_reported() -> None:
with pytest.raises(DonutError) as raised:
DonutClient(env={"DONUT_API_TOKEN": "t", "DONUT_API_PORT": "not-a-number"})
assert "DONUT_API_PORT" in str(raised.value)
def test_an_unsupported_scheme_is_refused() -> None:
with pytest.raises(DonutError):
DonutClient(base_url="ftp://127.0.0.1:9999", token="t", env={})
def test_the_websocket_address_is_built_from_the_same_base() -> None:
client = DonutClient(token="t", port=10108, env={})
assert (
client.remote_session_cdp_url("s 1")
== "ws://127.0.0.1:10108/v1/remote-sessions/s%201/cdp"
)
def test_a_reopened_client_still_works(fake: FakeDonut) -> None:
"""`close()` drops the socket; the next call has to open a new one."""
with DonutClient(token="t", port=fake.port, timeout=5.0, env={}) as client:
client.list_profiles()
client.close()
client.list_profiles()
assert len(fake.requests) == 2
+73
View File
@@ -0,0 +1,73 @@
"""The SDK cannot silently drift from the app's API.
``sdk/api-paths.json`` is generated from ``src-tauri/src/api_server.rs`` and
lists every operation the desktop app publishes. These tests hold it against
the SDK's own table in both directions, so a new endpoint in the app fails here
until it is wrapped or deliberately omitted with a reason.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, Set, Tuple
from donutbrowser import DonutClient
from donutbrowser.coverage import OMITTED, OPERATIONS
SNAPSHOT = Path(__file__).resolve().parents[2] / "api-paths.json"
def published() -> Set[Tuple[str, str]]:
document: Dict[str, Any] = json.loads(SNAPSHOT.read_text(encoding="utf-8"))
return {
(operation["method"], operation["path"]) for operation in document["operations"]
}
def test_the_snapshot_is_readable_and_not_empty() -> None:
document = json.loads(SNAPSHOT.read_text(encoding="utf-8"))
assert document["source"] == "src-tauri/src/api_server.rs"
assert document["operation_count"] == len(document["operations"])
assert document["operation_count"] > 0
assert len(published()) == document["operation_count"], "the app has two identical operations"
def test_every_published_operation_is_wrapped_or_omitted() -> None:
known = set(OPERATIONS) | set(OMITTED)
missing = sorted(published() - known)
assert not missing, (
"the app publishes operations this SDK does not handle: "
f"{missing}. Wrap each one, or add it to coverage.OMITTED with a reason."
)
def test_the_sdk_claims_nothing_the_app_does_not_publish() -> None:
stale = sorted((set(OPERATIONS) | set(OMITTED)) - published())
assert not stale, (
"this SDK handles operations the app no longer publishes: "
f"{stale}. Regenerate the snapshot with sdk/tools/extract-api-paths.py, "
"then drop or fix each entry."
)
def test_an_operation_is_either_wrapped_or_omitted_but_not_both() -> None:
both = sorted(set(OPERATIONS) & set(OMITTED))
assert not both, f"listed twice: {both}"
def test_every_omission_gives_a_reason() -> None:
for operation, reason in OMITTED.items():
assert len(reason.strip()) > 40, f"{operation} is omitted without a real reason"
def test_every_wrapped_operation_names_a_real_method() -> None:
for operation, method_name in OPERATIONS.items():
attribute = getattr(DonutClient, method_name, None)
assert callable(attribute), f"{operation} names {method_name}, which is not a method"
def test_no_two_operations_share_a_method() -> None:
names = list(OPERATIONS.values())
duplicates = sorted({name for name in names if names.count(name) > 1})
assert not duplicates, f"one method is claimed by several operations: {duplicates}"
+168
View File
@@ -0,0 +1,168 @@
"""Each status the app documents raises its own exception."""
from __future__ import annotations
import json
import pytest
from fake_donut import FakeDonut, QueuedResponse
from donutbrowser import (
BadGateway,
Conflict,
DonutAPIError,
DonutClient,
DonutConnectionError,
DonutError,
Forbidden,
NotFound,
PaymentRequired,
RateLimited,
RequestTimeout,
ServerError,
ServiceUnavailable,
Unauthorized,
ValidationError,
)
STATUS_TO_ERROR = [
(400, ValidationError),
(401, Unauthorized),
(402, PaymentRequired),
(403, Forbidden),
(404, NotFound),
(408, RequestTimeout),
(409, Conflict),
(429, RateLimited),
(500, ServerError),
(502, BadGateway),
(503, ServiceUnavailable),
]
@pytest.mark.parametrize("status,expected", STATUS_TO_ERROR)
def test_status_maps_to_its_exception(
client: DonutClient, fake: FakeDonut, status: int, expected: type
) -> None:
fake.enqueue_error(status, "something went wrong")
with pytest.raises(expected) as raised:
client.list_profiles()
assert raised.value.status == status
assert raised.value.body == "something went wrong"
assert raised.value.method == "GET"
assert raised.value.path == "/v1/profiles"
def test_every_error_is_a_donut_error(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(404, "PROFILE_NOT_FOUND")
with pytest.raises(DonutError):
client.get_profile("nope")
def test_the_five_hundreds_share_one_base(client: DonutClient, fake: FakeDonut) -> None:
"""`except ServerError` has to catch 502 and 503 as well as 500."""
for status in (500, 502, 503):
fake.enqueue_error(status, "upstream")
with pytest.raises(ServerError):
client.list_profiles()
def test_rate_limited_carries_retry_after(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(
429,
"automation request rate limit exceeded",
headers=(("Retry-After", "42"),),
)
with pytest.raises(RateLimited) as raised:
client.run_profile("p1")
assert raised.value.retry_after == 42
def test_rate_limited_without_the_header_is_still_raised(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_error(429, "slow down")
with pytest.raises(RateLimited) as raised:
client.run_profile("p1")
assert raised.value.retry_after is None
def test_an_unreadable_retry_after_does_not_break_the_error(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_error(429, "slow down", headers=(("Retry-After", "Wed, 21 Oct 2026 07:28:00 GMT"),))
with pytest.raises(RateLimited) as raised:
client.run_profile("p1")
assert raised.value.retry_after is None
def test_a_structured_code_body_is_decoded(client: DonutClient, fake: FakeDonut) -> None:
"""The app shares `{"code": ...}` strings with its own frontend."""
fake.enqueue_error(400, json.dumps({"code": "NAME_CANNOT_BE_EMPTY"}))
with pytest.raises(ValidationError) as raised:
client.create_group(name="")
assert raised.value.code == "NAME_CANNOT_BE_EMPTY"
assert raised.value.params == {}
def test_a_structured_code_body_keeps_its_params(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(409, json.dumps({"code": "PROFILE_LOCKED_BY_MEMBER", "params": {"n": "5"}}))
with pytest.raises(Conflict) as raised:
client.run_profile("p1")
assert raised.value.code == "PROFILE_LOCKED_BY_MEMBER"
assert raised.value.params == {"n": "5"}
def test_a_plain_text_body_leaves_code_unset(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(400, "invalid browser")
with pytest.raises(ValidationError) as raised:
client.create_profile(name="x", browser="chromium")
assert raised.value.code is None
assert raised.value.body == "invalid browser"
def test_an_undocumented_status_still_raises_something_catchable(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_error(418, "teapot")
with pytest.raises(DonutAPIError) as raised:
client.list_profiles()
assert raised.value.status == 418
def test_an_undocumented_server_status_is_a_server_error(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_error(504, "gateway timeout")
with pytest.raises(ServerError):
client.list_profiles()
def test_the_message_names_the_call(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(404, "Profile not found")
with pytest.raises(NotFound) as raised:
client.get_profile("missing")
assert "404" in str(raised.value)
assert "GET /v1/profiles/missing" in str(raised.value)
def test_an_unreachable_app_is_not_an_api_error(fake: FakeDonut) -> None:
port = fake.port
fake.stop()
with DonutClient(token="t", port=port, timeout=2.0, env={}) as client:
with pytest.raises(DonutConnectionError) as raised:
client.list_profiles()
assert "Local API" in str(raised.value)
def test_a_missing_token_fails_before_any_request() -> None:
with pytest.raises(DonutError) as raised:
DonutClient(env={})
assert "DONUT_API_TOKEN" in str(raised.value)
def test_a_non_json_answer_is_reported_as_such(client: DonutClient, fake: FakeDonut) -> None:
fake.responses.append(QueuedResponse(status=200, body="<html>nope</html>"))
with pytest.raises(DonutError) as raised:
client.list_profiles()
assert "not JSON" in str(raised.value)
+721
View File
@@ -0,0 +1,721 @@
"""Every client method sends exactly the request the app documents.
The table below is the whole public surface. Each row names a method, the
arguments to call it with, and the request that must appear on the wire: the
verb, the concrete path, the query string and the JSON body. ``operation`` is
the path template the app publishes, which ties this file to
``donutbrowser.coverage.OPERATIONS`` and, through it, to ``sdk/api-paths.json``.
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Tuple
import pytest
from fake_donut import FakeDonut
from donutbrowser import DonutClient
from donutbrowser.coverage import OPERATIONS
Case = Tuple[
str, # client method
Tuple[Any, ...], # positional arguments
Dict[str, Any], # keyword arguments
str, # expected verb
str, # expected concrete path
Optional[Dict[str, Any]], # expected JSON body, or None for no body
Dict[str, str], # expected query string
str, # operation template, as published by the app
]
LOCATOR = {"role": "button", "name": "Sign in"}
CASES: List[Case] = [
# -- profiles ----------------------------------------------------------
("list_profiles", (), {}, "GET", "/v1/profiles", None, {}, "/v1/profiles"),
("get_profile", ("p1",), {}, "GET", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"),
(
"create_profile",
(),
{"name": "Shopper", "browser": "wayfern", "tags": ["eu"], "ephemeral": True},
"POST",
"/v1/profiles",
{"name": "Shopper", "browser": "wayfern", "tags": ["eu"], "ephemeral": True},
{},
"/v1/profiles",
),
(
"create_profile",
(),
{"name": "Bare", "browser": "wayfern"},
"POST",
"/v1/profiles",
{"name": "Bare", "browser": "wayfern"},
{},
"/v1/profiles",
),
(
"update_profile",
("p1",),
{"name": "Renamed", "proxy_id": "", "clear_on_close": False},
"PUT",
"/v1/profiles/p1",
{"name": "Renamed", "proxy_id": "", "clear_on_close": False},
{},
"/v1/profiles/{id}",
),
("delete_profile", ("p1",), {}, "DELETE", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"),
(
"run_profile",
("p1",),
{"url": "https://example.com", "headless": True},
"POST",
"/v1/profiles/p1/run",
{"url": "https://example.com", "headless": True},
{},
"/v1/profiles/{id}/run",
),
(
"run_profile_remote",
("p1",),
{"url": "https://example.com"},
"POST",
"/v1/profiles/p1/run-remote",
{"url": "https://example.com"},
{},
"/v1/profiles/{id}/run-remote",
),
(
"set_profile_cloud_sync",
("p1",),
{"mode": "Regular"},
"POST",
"/v1/profiles/p1/cloud-sync",
{"mode": "Regular"},
{},
"/v1/profiles/{id}/cloud-sync",
),
(
"open_url",
("p1", "https://example.com/page"),
{},
"POST",
"/v1/profiles/p1/open-url",
{"url": "https://example.com/page"},
{},
"/v1/profiles/{id}/open-url",
),
(
"kill_profile",
("p1",),
{},
"POST",
"/v1/profiles/p1/kill",
None,
{},
"/v1/profiles/{id}/kill",
),
(
"batch_run_profiles",
(["p1", "p2"],),
{"headless": False},
"POST",
"/v1/profiles/batch/run",
{"profile_ids": ["p1", "p2"], "headless": False},
{},
"/v1/profiles/batch/run",
),
(
"batch_stop_profiles",
(["p1", "p2"],),
{},
"POST",
"/v1/profiles/batch/stop",
{"profile_ids": ["p1", "p2"]},
{},
"/v1/profiles/batch/stop",
),
(
"distribute_proxies",
([{"profile_id": "p1", "proxy_id": "x1"}, {"profile_id": "p2", "proxy_id": "x2"}],),
{},
"POST",
"/v1/profiles/distribute-proxies",
{
"pairs": [
{"profile_id": "p1", "proxy_id": "x1"},
{"profile_id": "p2", "proxy_id": "x2"},
]
},
{},
"/v1/profiles/distribute-proxies",
),
(
"detect_import_profiles",
(),
{"folder": "/Users/x/Chrome"},
"GET",
"/v1/profiles/import/detect",
None,
{"folder": "/Users/x/Chrome"},
"/v1/profiles/import/detect",
),
(
"detect_import_profiles",
(),
{},
"GET",
"/v1/profiles/import/detect",
None,
{},
"/v1/profiles/import/detect",
),
(
"import_profiles",
([{"source_path": "/tmp/src", "new_profile_name": "Imported"}],),
{"duplicate_strategy": "skip"},
"POST",
"/v1/profiles/import",
{
"items": [{"source_path": "/tmp/src", "new_profile_name": "Imported"}],
"duplicate_strategy": "skip",
},
{},
"/v1/profiles/import",
),
(
"import_profile_cookies",
("p1",),
{"content": "[]"},
"POST",
"/v1/profiles/p1/cookies/import",
{"content": "[]"},
{},
"/v1/profiles/{id}/cookies/import",
),
# -- agent -------------------------------------------------------------
(
"agent_perceive",
("p1",),
{"viewport_only": True, "max_bytes": 2048},
"POST",
"/v1/profiles/p1/agent/perceive",
{"max_bytes": 2048, "viewport_only": True},
{},
"/v1/profiles/{id}/agent/perceive",
),
(
"agent_perceive",
("p1",),
{},
"POST",
"/v1/profiles/p1/agent/perceive",
{},
{},
"/v1/profiles/{id}/agent/perceive",
),
(
"agent_resolve_locator",
("p1",),
{"locator": LOCATOR, "candidate_limit": 5},
"POST",
"/v1/profiles/p1/agent/resolve-locator",
{"locator": LOCATOR, "candidate_limit": 5},
{},
"/v1/profiles/{id}/agent/resolve-locator",
),
(
"agent_click",
("p1",),
{"locator": LOCATOR, "button": "right", "click_count": 2},
"POST",
"/v1/profiles/p1/agent/click",
{"locator": LOCATOR, "button": "right", "click_count": 2},
{},
"/v1/profiles/{id}/agent/click",
),
(
"agent_type",
("p1",),
{"locator": LOCATOR, "text": "hello", "clear_first": False, "wpm": 55.0},
"POST",
"/v1/profiles/p1/agent/type",
{"locator": LOCATOR, "text": "hello", "clear_first": False, "wpm": 55.0},
{},
"/v1/profiles/{id}/agent/type",
),
(
"agent_extract",
("p1",),
{
"container": {"role": "listitem"},
"field_map": [{"key": "title", "locator": {"role": "heading"}, "source": "text"}],
"max_pages": 3,
},
"POST",
"/v1/profiles/p1/agent/extract",
{
"container": {"role": "listitem"},
"field_map": [{"key": "title", "locator": {"role": "heading"}, "source": "text"}],
"max_pages": 3,
},
{},
"/v1/profiles/{id}/agent/extract",
),
(
"agent_pick",
("p1",),
{"timeout_ms": 15000},
"POST",
"/v1/profiles/p1/agent/pick",
{"timeout_ms": 15000},
{},
"/v1/profiles/{id}/agent/pick",
),
# -- remote sessions ---------------------------------------------------
(
"list_remote_sessions",
(),
{},
"GET",
"/v1/remote-sessions",
None,
{},
"/v1/remote-sessions",
),
(
"get_remote_session",
("s1",),
{},
"GET",
"/v1/remote-sessions/s1",
None,
{},
"/v1/remote-sessions/{id}",
),
(
"stop_remote_session",
("s1",),
{},
"DELETE",
"/v1/remote-sessions/s1",
None,
{},
"/v1/remote-sessions/{id}",
),
("get_remote_hours", (), {}, "GET", "/v1/remote-hours", None, {}, "/v1/remote-hours"),
# -- cookie bot --------------------------------------------------------
(
"list_cookie_bot_schedules",
(),
{"scope": "team"},
"GET",
"/v1/cookie-bot/schedules",
None,
{"scope": "team"},
"/v1/cookie-bot/schedules",
),
(
"get_cookie_bot_schedule",
("p1",),
{},
"GET",
"/v1/cookie-bot/schedules/p1",
None,
{},
"/v1/cookie-bot/schedules/{profile_id}",
),
(
"set_cookie_bot_schedule",
("p1",),
{
"enabled": True,
"run_at_minute": 120,
"days_mask": 31,
"timezone": "Europe/Berlin",
"preset": "steady",
"max_minutes": 45,
"sites": ["https://example.com"],
"acknowledge_conflict": True,
},
"PUT",
"/v1/cookie-bot/schedules/p1",
{
"enabled": True,
"run_at_minute": 120,
"days_mask": 31,
"timezone": "Europe/Berlin",
"preset": "steady",
"max_minutes": 45,
"sites": ["https://example.com"],
"acknowledge_conflict": True,
},
{},
"/v1/cookie-bot/schedules/{profile_id}",
),
(
"delete_cookie_bot_schedule",
("p1",),
{},
"DELETE",
"/v1/cookie-bot/schedules/p1",
None,
{},
"/v1/cookie-bot/schedules/{profile_id}",
),
(
"get_cookie_bot_conflicts",
("p1",),
{"run_at_minute": 90, "timezone": "UTC", "days_mask": 7},
"GET",
"/v1/cookie-bot/conflicts",
None,
{"profile_id": "p1", "run_at_minute": "90", "timezone": "UTC", "days_mask": "7"},
"/v1/cookie-bot/conflicts",
),
(
"list_cookie_bot_runs",
(),
{"profile_id": "p1", "limit": 10, "before": "cursor-1"},
"GET",
"/v1/cookie-bot/runs",
None,
{"profile_id": "p1", "limit": "10", "before": "cursor-1"},
"/v1/cookie-bot/runs",
),
(
"start_cookie_bot_run",
(),
{"profile_id": "p1", "max_minutes": 30},
"POST",
"/v1/cookie-bot/runs",
{"profile_id": "p1", "max_minutes": 30},
{},
"/v1/cookie-bot/runs",
),
(
"cancel_cookie_bot_run",
("r1",),
{},
"DELETE",
"/v1/cookie-bot/runs/r1",
None,
{},
"/v1/cookie-bot/runs/{run_id}",
),
(
"list_cookie_bot_presets",
(),
{},
"GET",
"/v1/cookie-bot/presets",
None,
{},
"/v1/cookie-bot/presets",
),
(
"get_cookie_bot_usage",
(),
{"period": "2026-08"},
"GET",
"/v1/cookie-bot/usage",
None,
{"period": "2026-08"},
"/v1/cookie-bot/usage",
),
# -- groups and tags ---------------------------------------------------
("list_groups", (), {}, "GET", "/v1/groups", None, {}, "/v1/groups"),
("get_group", ("g1",), {}, "GET", "/v1/groups/g1", None, {}, "/v1/groups/{id}"),
("create_group", (), {"name": "Retail"}, "POST", "/v1/groups", {"name": "Retail"}, {}, "/v1/groups"),
(
"update_group",
("g1",),
{"name": "Retail EU"},
"PUT",
"/v1/groups/g1",
{"name": "Retail EU"},
{},
"/v1/groups/{id}",
),
("delete_group", ("g1",), {}, "DELETE", "/v1/groups/g1", None, {}, "/v1/groups/{id}"),
("list_tags", (), {}, "GET", "/v1/tags", None, {}, "/v1/tags"),
# -- proxies -----------------------------------------------------------
("list_proxies", (), {}, "GET", "/v1/proxies", None, {}, "/v1/proxies"),
("get_proxy", ("x1",), {}, "GET", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"),
(
"create_proxy",
(),
{"name": "EU", "proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080}},
"POST",
"/v1/proxies",
{"name": "EU", "proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080}},
{},
"/v1/proxies",
),
(
"update_proxy",
("x1",),
{"name": "EU 2"},
"PUT",
"/v1/proxies/x1",
{"name": "EU 2"},
{},
"/v1/proxies/{id}",
),
("delete_proxy", ("x1",), {}, "DELETE", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"),
(
"import_proxies",
(),
{"format": "txt", "content": "h:1:u:p", "name_prefix": "EU"},
"POST",
"/v1/proxies/import",
{"format": "txt", "content": "h:1:u:p", "name_prefix": "EU"},
{},
"/v1/proxies/import",
),
# -- vpns --------------------------------------------------------------
("list_vpns", (), {}, "GET", "/v1/vpns", None, {}, "/v1/vpns"),
("get_vpn", ("v1",), {}, "GET", "/v1/vpns/v1", None, {}, "/v1/vpns/{id}"),
("export_vpn", ("v1",), {}, "GET", "/v1/vpns/v1/export", None, {}, "/v1/vpns/{id}/export"),
(
"import_vpn",
(),
{"content": "[Interface]", "filename": "eu.conf"},
"POST",
"/v1/vpns/import",
{"content": "[Interface]", "filename": "eu.conf"},
{},
"/v1/vpns/import",
),
(
"create_vpn",
(),
{"name": "EU", "vpn_type": "WireGuard", "config_data": "[Interface]"},
"POST",
"/v1/vpns",
{"name": "EU", "vpn_type": "WireGuard", "config_data": "[Interface]"},
{},
"/v1/vpns",
),
(
"update_vpn",
("v1",),
{"name": "EU 2"},
"PUT",
"/v1/vpns/v1",
{"name": "EU 2"},
{},
"/v1/vpns/{id}",
),
("delete_vpn", ("v1",), {}, "DELETE", "/v1/vpns/v1", None, {}, "/v1/vpns/{id}"),
# -- extensions --------------------------------------------------------
("list_extensions", (), {}, "GET", "/v1/extensions", None, {}, "/v1/extensions"),
("get_extension", ("e1",), {}, "GET", "/v1/extensions/e1", None, {}, "/v1/extensions/{id}"),
(
"create_extension",
(),
{"name": "Blocker", "file_name": "b.crx", "file_data_base64": "AAAA"},
"POST",
"/v1/extensions",
{"name": "Blocker", "file_name": "b.crx", "file_data_base64": "AAAA"},
{},
"/v1/extensions",
),
(
"update_extension",
("e1",),
{"name": "Blocker 2", "link": True},
"PUT",
"/v1/extensions/e1",
{"name": "Blocker 2", "link": True},
{},
"/v1/extensions/{id}",
),
(
"delete_extension",
("e1",),
{},
"DELETE",
"/v1/extensions/e1",
None,
{},
"/v1/extensions/{id}",
),
(
"list_extension_groups",
(),
{},
"GET",
"/v1/extension-groups",
None,
{},
"/v1/extension-groups",
),
(
"get_extension_group",
("eg1",),
{},
"GET",
"/v1/extension-groups/eg1",
None,
{},
"/v1/extension-groups/{id}",
),
(
"create_extension_group",
(),
{"name": "Adblock set"},
"POST",
"/v1/extension-groups",
{"name": "Adblock set"},
{},
"/v1/extension-groups",
),
(
"update_extension_group",
("eg1",),
{"extension_ids": ["e1", "e2"]},
"PUT",
"/v1/extension-groups/eg1",
{"extension_ids": ["e1", "e2"]},
{},
"/v1/extension-groups/{id}",
),
(
"delete_extension_group",
("eg1",),
{},
"DELETE",
"/v1/extension-groups/eg1",
None,
{},
"/v1/extension-groups/{id}",
),
(
"add_extension_to_group",
("eg1", "e1"),
{},
"POST",
"/v1/extension-groups/eg1/extensions/e1",
None,
{},
"/v1/extension-groups/{id}/extensions/{extension_id}",
),
(
"remove_extension_from_group",
("eg1", "e1"),
{},
"DELETE",
"/v1/extension-groups/eg1/extensions/e1",
None,
{},
"/v1/extension-groups/{id}/extensions/{extension_id}",
),
# -- browsers ----------------------------------------------------------
(
"download_browser",
(),
{"browser": "wayfern", "version": "152.0.1"},
"POST",
"/v1/browsers/download",
{"browser": "wayfern", "version": "152.0.1"},
{},
"/v1/browsers/download",
),
(
"list_browser_versions",
("wayfern",),
{},
"GET",
"/v1/browsers/wayfern/versions",
None,
{},
"/v1/browsers/{browser}/versions",
),
(
"is_browser_downloaded",
("wayfern", "152.0.1"),
{},
"GET",
"/v1/browsers/wayfern/versions/152.0.1/downloaded",
None,
{},
"/v1/browsers/{browser}/versions/{version}/downloaded",
),
]
@pytest.mark.parametrize(
"case", CASES, ids=[f"{case[0]}[{index}]" for index, case in enumerate(CASES)]
)
def test_method_sends_the_documented_request(
client: DonutClient, fake: FakeDonut, case: Case
) -> None:
name, args, kwargs, verb, path, body, query, operation = case
getattr(client, name)(*args, **kwargs)
sent = fake.last
assert sent.method == verb
assert sent.path == path
assert sent.query == query
assert sent.json == body
assert OPERATIONS[(verb, operation)] == name
def test_every_client_method_is_exercised_here() -> None:
"""No method may be added to the table of operations without a case above."""
covered = {case[0] for case in CASES}
missing = sorted(set(OPERATIONS.values()) - covered)
assert not missing, f"these wrapped operations have no request test: {missing}"
def test_the_token_travels_as_a_bearer_header(client: DonutClient, fake: FakeDonut) -> None:
client.list_profiles()
sent = fake.last
assert sent.header("Authorization") == "Bearer test-token-abc123"
assert sent.header("Accept") == "application/json"
assert sent.header("Content-Type") is None, "a GET must not claim to carry JSON"
def test_a_body_is_sent_as_json(client: DonutClient, fake: FakeDonut) -> None:
client.create_group(name="Retail")
sent = fake.last
assert sent.header("Content-Type") == "application/json"
assert json.loads(sent.body.decode()) == {"name": "Retail"}
def test_path_ids_are_escaped(client: DonutClient, fake: FakeDonut) -> None:
"""An id can never break out of its own path segment."""
client.get_profile("a/b c?d")
assert fake.last.path == "/v1/profiles/a%2Fb%20c%3Fd"
def test_none_arguments_are_left_out_of_the_body(client: DonutClient, fake: FakeDonut) -> None:
client.update_profile("p1", name="Only this")
assert fake.last.json == {"name": "Only this"}
def test_an_empty_string_still_reaches_the_app(client: DonutClient, fake: FakeDonut) -> None:
"""`proxy_id=""` is how the app is told to detach a proxy, so it must survive."""
client.update_profile("p1", proxy_id="")
assert fake.last.json == {"proxy_id": ""}
def test_a_no_content_answer_becomes_none(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_empty(204)
assert client.delete_profile("p1") is None
def test_a_json_answer_is_returned_as_sent(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_json({"profiles": [{"id": "p1", "name": "Shopper"}], "total": 1})
assert client.list_profiles() == {
"profiles": [{"id": "p1", "name": "Shopper"}],
"total": 1,
}
def test_a_bare_boolean_answer_is_returned(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_json(True)
assert client.is_browser_downloaded("wayfern", "152.0.1") is True
+92
View File
@@ -0,0 +1,92 @@
"""`with client.run(...)` launches, hands over the CDP endpoint, and stops."""
from __future__ import annotations
import pytest
from fake_donut import FakeDonut
from donutbrowser import Conflict, DonutClient, DonutError
RUN_BODY = {"profile_id": "p1", "remote_debugging_port": 9222, "headless": True}
def test_the_block_gets_the_cdp_endpoint(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_json(RUN_BODY)
fake.enqueue_empty(204)
with client.run("p1", url="https://example.com", headless=True) as session:
assert session.remote_debugging_port == 9222
assert session.headless is True
assert session.cdp_url == "http://127.0.0.1:9222"
assert session.response == RUN_BODY
assert [(sent.method, sent.path) for sent in fake.requests] == [
("POST", "/v1/profiles/p1/run"),
("POST", "/v1/profiles/p1/kill"),
]
assert fake.requests[0].json == {"url": "https://example.com", "headless": True}
def test_nothing_launches_until_the_block_is_entered(
client: DonutClient, fake: FakeDonut
) -> None:
session = client.run("p1")
assert session.remote_debugging_port is None
assert fake.requests == []
def test_the_browser_is_stopped_when_the_block_raises(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_json(RUN_BODY)
fake.enqueue_empty(204)
with pytest.raises(ZeroDivisionError):
with client.run("p1"):
raise ZeroDivisionError("the body failed")
assert [sent.path for sent in fake.requests] == [
"/v1/profiles/p1/run",
"/v1/profiles/p1/kill",
]
def test_a_failed_stop_never_hides_why_the_block_failed(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_json(RUN_BODY)
fake.enqueue_error(409, "PROFILE_LOCKED_ELSEWHERE")
session = client.run("p1")
with pytest.raises(ZeroDivisionError):
with session:
raise ZeroDivisionError("the body failed")
assert isinstance(session.cleanup_error, Conflict)
def test_a_failed_stop_is_raised_when_the_block_was_fine(
client: DonutClient, fake: FakeDonut
) -> None:
fake.enqueue_json(RUN_BODY)
fake.enqueue_error(503, "the fleet could not be reached")
with pytest.raises(DonutError):
with client.run("p1"):
pass
def test_a_failed_launch_stops_nothing(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_error(409, "PROFILE_RUNNING")
with pytest.raises(Conflict):
with client.run("p1"):
pytest.fail("the block must not run when the launch failed")
assert [sent.path for sent in fake.requests] == ["/v1/profiles/p1/run"]
def test_the_cdp_url_is_refused_before_the_block(client: DonutClient) -> None:
session = client.run("p1")
with pytest.raises(DonutError):
_ = session.cdp_url
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Regenerate sdk/api-paths.json from the Rust REST server.
The served /openapi.json comes from the hand-maintained `ApiDoc` derive in
`src-tauri/src/api_server.rs`, not from the axum router, so this script reads
the same two things the document is built from:
* every `#[utoipa::path(...)]` annotation (its verb and path), and
* the `paths(...)` list inside `#[openapi(...)]`.
An annotation that is not in `paths(...)` never reaches the served document, so
the two lists are compared here and a difference fails the run. The result is a
snapshot both SDK test suites read to prove they cover the whole API.
Usage (from anywhere):
python3 sdk/tools/extract-api-paths.py
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SOURCE = REPO_ROOT / "src-tauri" / "src" / "api_server.rs"
SNAPSHOT = REPO_ROOT / "sdk" / "api-paths.json"
VERBS = ("get", "post", "put", "delete", "patch", "head", "options")
def read_annotations(lines: list[str]) -> list[dict[str, str]]:
"""Every `#[utoipa::path(...)]` block, paired with the fn it decorates."""
operations: list[dict[str, str]] = []
index = 0
while index < len(lines):
if lines[index].strip() != "#[utoipa::path(":
index += 1
continue
depth = 0
end = index
while end < len(lines):
depth += lines[end].count("(") - lines[end].count(")")
if depth == 0 and end > index:
break
end += 1
block = lines[index : end + 1]
method = next(
(line.strip().rstrip(",") for line in block if line.strip().rstrip(",") in VERBS),
None,
)
path_match = next(
(re.search(r'path\s*=\s*"([^"]+)"', line) for line in block if "path = " in line),
None,
)
name_match = None
for line in lines[end + 1 : end + 4]:
name_match = re.search(r"\bfn\s+(\w+)\s*\(", line)
if name_match:
break
if method is None or path_match is None or name_match is None:
raise SystemExit(
f"{SOURCE}:{index + 1}: could not read a verb, a path and a fn name "
"out of this #[utoipa::path] block"
)
operations.append(
{
"operation_id": name_match.group(1),
"method": method.upper(),
"path": path_match.group(1),
}
)
index = end + 1
return operations
def read_apidoc_paths(text: str) -> list[str]:
"""The operation ids listed in `#[openapi(paths(...))]`."""
start = text.index("#[openapi(")
listed = text.index("paths(", start) + len("paths(")
depth = 1
end = listed
while depth:
if text[end] == "(":
depth += 1
elif text[end] == ")":
depth -= 1
if depth == 0:
break
end += 1
body = re.sub(r"//[^\n]*", "", text[listed:end])
return [item.strip() for item in body.split(",") if item.strip()]
def main() -> int:
text = SOURCE.read_text(encoding="utf-8")
annotated = read_annotations(text.split("\n"))
listed = read_apidoc_paths(text)
annotated_ids = {operation["operation_id"] for operation in annotated}
listed_ids = set(listed)
unpublished = sorted(annotated_ids - listed_ids)
unknown = sorted(listed_ids - annotated_ids)
if unpublished or unknown:
for name in unpublished:
print(
f"error: {name} carries a #[utoipa::path] but is missing from "
"ApiDoc paths(...), so it is absent from the served spec",
file=sys.stderr,
)
for name in unknown:
print(
f"error: ApiDoc paths(...) lists {name}, which has no "
"#[utoipa::path] annotation in this file",
file=sys.stderr,
)
return 1
operations = sorted(annotated, key=lambda op: (op["path"], op["method"]))
snapshot = {
"source": "src-tauri/src/api_server.rs",
"regenerate_with": "python3 sdk/tools/extract-api-paths.py",
"description": (
"Every operation the desktop app publishes in its /openapi.json. The "
"SDK test suites assert this list and their own coverage tables match "
"exactly, so an endpoint added to the app fails the SDK tests until it "
"is either wrapped or deliberately listed as omitted."
),
"operation_count": len(operations),
"operations": operations,
}
SNAPSHOT.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8")
print(f"wrote {SNAPSHOT.relative_to(REPO_ROOT)} with {len(operations)} operations")
return 0
if __name__ == "__main__":
raise SystemExit(main())