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