refactor: cleanup

This commit is contained in:
zhom
2026-09-09 10:09:14 +04:00
parent 598d3bd513
commit dd42d46753
249 changed files with 67417 additions and 6659 deletions
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@donutbrowser/sdk",
"version": "0.1.0",
"description": "Thin client for the Donut Browser local REST API",
"license": "AGPL-3.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"files": [
"dist",
"README.md"
],
"engines": {
"node": ">=22"
},
"scripts": {
"test": "node --test test/*.test.mts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"keywords": [
"donut-browser",
"browser-automation",
"anti-detect",
"cdp"
],
"homepage": "https://donutbrowser.com",
"repository": {
"type": "git",
"url": "git+https://github.com/zhom/donutbrowser.git",
"directory": "sdk/node"
},
"devDependencies": {
"typescript": "^5.9.0"
}
}
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
/**
* Which app operation each client method wraps.
*
* This table is the SDK's half of a two-sided check. `sdk/api-paths.json` holds
* every operation the desktop app publishes, generated from
* `src-tauri/src/api_server.rs`. The test suite asserts the two agree exactly
* in both directions, so:
*
* - an endpoint added to the app fails the SDK tests until it is wrapped here,
* or listed in `OMITTED` with a reason, and
* - an entry here that the app no longer publishes fails too.
*
* The same table is mirrored in the Python package, and the same snapshot
* proves it.
*/
/** `"<VERB> <path template>"`, exactly as the app publishes it. */
export type OperationKey = string;
/** Operation to the name of the `DonutClient` method that calls it. */
export const OPERATIONS: ReadonlyMap<OperationKey, string> = new Map([
["POST /v1/browsers/download", "downloadBrowser"],
["GET /v1/browsers/{browser}/versions", "listBrowserVersions"],
["GET /v1/browsers/{browser}/versions/{version}/downloaded", "isBrowserDownloaded"],
["GET /v1/cookie-bot/conflicts", "getCookieBotConflicts"],
["GET /v1/cookie-bot/presets", "listCookieBotPresets"],
["GET /v1/cookie-bot/runs", "listCookieBotRuns"],
["POST /v1/cookie-bot/runs", "startCookieBotRun"],
["DELETE /v1/cookie-bot/runs/{run_id}", "cancelCookieBotRun"],
["GET /v1/cookie-bot/schedules", "listCookieBotSchedules"],
["DELETE /v1/cookie-bot/schedules/{profile_id}", "deleteCookieBotSchedule"],
["GET /v1/cookie-bot/schedules/{profile_id}", "getCookieBotSchedule"],
["PUT /v1/cookie-bot/schedules/{profile_id}", "setCookieBotSchedule"],
["GET /v1/cookie-bot/usage", "getCookieBotUsage"],
["GET /v1/extension-groups", "listExtensionGroups"],
["POST /v1/extension-groups", "createExtensionGroup"],
["DELETE /v1/extension-groups/{id}", "deleteExtensionGroup"],
["GET /v1/extension-groups/{id}", "getExtensionGroup"],
["PUT /v1/extension-groups/{id}", "updateExtensionGroup"],
["DELETE /v1/extension-groups/{id}/extensions/{extension_id}", "removeExtensionFromGroup"],
["POST /v1/extension-groups/{id}/extensions/{extension_id}", "addExtensionToGroup"],
["GET /v1/extensions", "listExtensions"],
["POST /v1/extensions", "createExtension"],
["DELETE /v1/extensions/{id}", "deleteExtension"],
["GET /v1/extensions/{id}", "getExtension"],
["PUT /v1/extensions/{id}", "updateExtension"],
["GET /v1/groups", "listGroups"],
["POST /v1/groups", "createGroup"],
["DELETE /v1/groups/{id}", "deleteGroup"],
["GET /v1/groups/{id}", "getGroup"],
["PUT /v1/groups/{id}", "updateGroup"],
["GET /v1/profiles", "listProfiles"],
["POST /v1/profiles", "createProfile"],
["POST /v1/profiles/batch/run", "batchRunProfiles"],
["POST /v1/profiles/batch/stop", "batchStopProfiles"],
["POST /v1/profiles/distribute-proxies", "distributeProxies"],
["POST /v1/profiles/import", "importProfiles"],
["GET /v1/profiles/import/detect", "detectImportProfiles"],
["DELETE /v1/profiles/{id}", "deleteProfile"],
["GET /v1/profiles/{id}", "getProfile"],
["PUT /v1/profiles/{id}", "updateProfile"],
["POST /v1/profiles/{id}/agent/click", "agentClick"],
["POST /v1/profiles/{id}/agent/extract", "agentExtract"],
["POST /v1/profiles/{id}/agent/perceive", "agentPerceive"],
["POST /v1/profiles/{id}/agent/pick", "agentPick"],
["POST /v1/profiles/{id}/agent/resolve-locator", "agentResolveLocator"],
["POST /v1/profiles/{id}/agent/type", "agentType"],
["POST /v1/profiles/{id}/cloud-sync", "setProfileCloudSync"],
["POST /v1/profiles/{id}/cookies/import", "importProfileCookies"],
["POST /v1/profiles/{id}/kill", "killProfile"],
["POST /v1/profiles/{id}/open-url", "openUrl"],
["POST /v1/profiles/{id}/run", "runProfile"],
["POST /v1/profiles/{id}/run-remote", "runProfileRemote"],
["GET /v1/proxies", "listProxies"],
["POST /v1/proxies", "createProxy"],
["POST /v1/proxies/import", "importProxies"],
["DELETE /v1/proxies/{id}", "deleteProxy"],
["GET /v1/proxies/{id}", "getProxy"],
["PUT /v1/proxies/{id}", "updateProxy"],
["GET /v1/remote-hours", "getRemoteHours"],
["GET /v1/remote-sessions", "listRemoteSessions"],
["DELETE /v1/remote-sessions/{id}", "stopRemoteSession"],
["GET /v1/remote-sessions/{id}", "getRemoteSession"],
["GET /v1/tags", "listTags"],
["GET /v1/vpns", "listVpns"],
["POST /v1/vpns", "createVpn"],
["POST /v1/vpns/import", "importVpn"],
["DELETE /v1/vpns/{id}", "deleteVpn"],
["GET /v1/vpns/{id}", "getVpn"],
["PUT /v1/vpns/{id}", "updateVpn"],
["GET /v1/vpns/{id}/export", "exportVpn"],
]);
/** Operations this SDK deliberately does not call, and why. */
export const OMITTED: ReadonlyMap<OperationKey, string> = new Map([
[
"GET /v1/remote-sessions/{id}/cdp",
"A WebSocket upgrade, not a request. fetch() cannot speak it, and bundling a " +
"websocket implementation would end this package's zero-dependency promise for " +
"one endpoint. DonutClient.remoteSessionCdpUrl() builds the ws:// address so a " +
"websocket library of the caller's choosing can connect, sending the same " +
"Authorization: Bearer header on the handshake.",
],
]);
+211
View File
@@ -0,0 +1,211 @@
/**
* Exceptions thrown by the Donut Browser SDK.
*
* The local REST API answers with a plain-text body and one of a small set of
* statuses. Each status means one thing, so each gets its own class and a
* caller can branch on `instanceof` instead of on a number:
*
* | Status | Class | Meaning |
* | -----: | --------------------- | ----------------------------------------- |
* | 400 | `ValidationError` | Malformed request, duplicate name |
* | 401 | `Unauthorized` | Missing or wrong bearer token |
* | 402 | `PaymentRequired` | Automation needs an active paid plan |
* | 403 | `Forbidden` | Terms not accepted, or not signed in |
* | 404 | `NotFound` | No such profile, group, proxy, ... |
* | 408 | `RequestTimeout` | `agent/pick` waited and nothing was picked |
* | 409 | `Conflict` | Something else holds the profile |
* | 429 | `RateLimited` | Quota spent; see `retryAfter` |
* | 500 | `ServerError` | Internal failure |
* | 502 | `BadGateway` | The browser or relay answered wrongly |
* | 503 | `ServiceUnavailable` | Cloud, fleet or lock service unreachable |
*
* Some bodies are the structured `{"code": ..., "params": {...}}` strings the
* desktop app shares with its own frontend. When one arrives, `code` and
* `params` are filled in; otherwise `code` is `null` and `body` holds the
* diagnostic text as sent.
*/
/** Base class for everything this package throws. */
export class DonutError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = new.target.name;
}
}
/**
* The app could not be reached at all.
*
* Usually means the local API is switched off, is listening on another port,
* or the desktop app is not running.
*/
export class DonutConnectionError extends DonutError {}
export interface DonutApiErrorInit {
method?: string;
path?: string;
headers?: Headers | Record<string, string>;
}
/** The app answered, and the answer was an error status. */
export class DonutApiError extends DonutError {
status: number;
body: string;
method: string;
path: string;
headers: Record<string, string>;
/** The `code` of a structured `{"code": ...}` body, else `null`. */
code: string | null;
/** The `params` of a structured body, else an empty object. */
params: Record<string, unknown>;
constructor(status: number, body: string, init: DonutApiErrorInit = {}) {
const method = init.method ?? "";
const path = init.path ?? "";
const headers = normaliseHeaders(init.headers);
let code: string | null = null;
let params: Record<string, unknown> = {};
const trimmed = body.trim();
if (trimmed.startsWith("{")) {
try {
const decoded: unknown = JSON.parse(trimmed);
if (decoded !== null && typeof decoded === "object") {
const record = decoded as Record<string, unknown>;
if (typeof record.code === "string") {
code = record.code;
if (record.params !== null && typeof record.params === "object") {
params = record.params as Record<string, unknown>;
}
}
}
} catch {
// Not JSON after all; the plain text below is the whole story.
}
}
const where = `${method} ${path}`.trim();
const detail = code ?? (trimmed || "(empty body)");
super(where ? `${status} on ${where}: ${detail}` : `${status}: ${detail}`);
this.status = status;
this.body = body;
this.method = method;
this.path = path;
this.headers = headers;
this.code = code;
this.params = params;
}
}
/** 400: the request was malformed, duplicated a name, or named something unsupported. */
export class ValidationError extends DonutApiError {}
/** 401: no bearer token, the wrong one, or the local API has no token stored. */
export class Unauthorized extends DonutApiError {}
/** 402: this action needs an active paid plan, or the proxy behind it lapsed. */
export class PaymentRequired extends DonutApiError {}
/** 403: the Wayfern terms are not accepted, or this desktop is not signed in. */
export class Forbidden extends DonutApiError {}
/** 404: no entity with that id. */
export class NotFound extends DonutApiError {}
/** 408: `agentPick` waited its whole timeout and nothing was picked. */
export class RequestTimeout extends DonutApiError {}
/** 409: something else holds the profile — a browser, a teammate, a remote session. */
export class Conflict extends DonutApiError {}
/**
* 500 and the other 5xx: the app, the fleet or an upstream failed.
*
* `BadGateway` and `ServiceUnavailable` extend this, so one
* `instanceof ServerError` covers every server-side failure.
*/
export class ServerError extends DonutApiError {}
/** 502: the browser or the relay did not answer the way it documents. */
export class BadGateway extends ServerError {}
/**
* 503: Donut cloud, the remote fleet, or the profile lock service is unreachable.
*
* Whatever was running keeps running: a 503 from `killProfile` or from stopping
* a remote session means the browser is still up, not that it stopped.
*/
export class ServiceUnavailable extends ServerError {}
/**
* 429: the shared automation quota is spent.
*
* `retryAfter` is the number of seconds the server asked the caller to wait,
* taken from the `Retry-After` response header. It is `null` only when the
* header is missing or unreadable.
*/
export class RateLimited extends DonutApiError {
retryAfter: number | null;
constructor(status: number, body: string, init: DonutApiErrorInit = {}) {
super(status, body, init);
const raw = this.headers["retry-after"];
const seconds = raw === undefined ? Number.NaN : Number.parseInt(raw.trim(), 10);
this.retryAfter = Number.isFinite(seconds) ? seconds : null;
}
}
function normaliseHeaders(
headers: Headers | Record<string, string> | undefined,
): Record<string, string> {
const result: Record<string, string> = {};
if (headers === undefined) {
return result;
}
if (typeof (headers as Headers).forEach === "function" && !Array.isArray(headers)) {
(headers as Headers).forEach((value, key) => {
result[key.toLowerCase()] = value;
});
return result;
}
for (const [key, value] of Object.entries(headers as Record<string, string>)) {
result[key.toLowerCase()] = value;
}
return result;
}
const BY_STATUS = new Map<number, typeof DonutApiError>([
[400, ValidationError],
[401, Unauthorized],
[402, PaymentRequired],
[403, Forbidden],
[404, NotFound],
[408, RequestTimeout],
[409, Conflict],
[429, RateLimited],
[500, ServerError],
[502, BadGateway],
[503, ServiceUnavailable],
]);
/**
* Build the error that belongs to `status`.
*
* A status with no class of its own becomes a plain `DonutApiError`, so a
* future status added to the app still throws something a caller can catch
* rather than escaping as a decode failure.
*/
export function errorForStatus(
status: number,
body: string,
init: DonutApiErrorInit = {},
): DonutApiError {
const known = BY_STATUS.get(status);
if (known !== undefined) {
return new known(status, body, init);
}
return status >= 500
? new ServerError(status, body, init)
: new DonutApiError(status, body, init);
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Donut Browser SDK: a thin client for the app's local REST API.
*
* The local API is off by default. Switch it on in the app under **Settings,
* Integrations, Local API, "Enable Local API Server"**, and copy the port and
* the authentication token from that screen.
*
* ```ts
* import { DonutClient } from "@donutbrowser/sdk";
*
* const client = new DonutClient({ token: "..." });
* await client.withProfile(profileId, { url: "https://example.com" }, async (session) => {
* console.log(session.cdpUrl);
* await client.agentClick(profileId, { locator: { role: "button", name: "Sign in" } });
* });
* ```
*/
export { DEFAULT_HOST, DEFAULT_PORT, DonutClient, RunSession } from "./client.mts";
export type { DonutClientOptions, RunProfileOptions } from "./client.mts";
export { OMITTED, OPERATIONS } from "./coverage.mts";
export type { OperationKey } from "./coverage.mts";
export {
BadGateway,
Conflict,
DonutApiError,
DonutConnectionError,
DonutError,
errorForStatus,
Forbidden,
NotFound,
PaymentRequired,
RateLimited,
RequestTimeout,
ServerError,
ServiceUnavailable,
Unauthorized,
ValidationError,
} from "./errors.mts";
export type { DonutApiErrorInit } from "./errors.mts";
export type * from "./types.mts";
+634
View File
@@ -0,0 +1,634 @@
/**
* Response shapes, spelled exactly the way the local API sends them.
*
* Every interface here mirrors a `ToSchema` struct in `src-tauri` field for
* field. A Rust `Option<T>` becomes an optional property.
*
* Two spellings live side by side because the app sends both. Most bodies are
* snake_case; the browser-facing agent types (`LocatorDescription`,
* `LocatorCandidate`, `PerceptionPage` and friends) carry the browser's own
* camelCase, because they are handed through from the browser rather than
* restated. `AgentClick` and `AgentTyping` are the exceptions inside the agent
* surface: they are snake_case with a single `match` key. These types follow
* the wire rather than tidying it, so a value read from one call can be passed
* straight into the next.
*/
/** The app's own JSON for a proxy's settings, declared `Object` in the spec. */
export type ProxySettings = Record<string, unknown>;
/** A Wayfern fingerprint/config blob, also declared `Object` in the spec. */
export type WayfernConfig = Record<string, unknown>;
/** Which implementation answered: the browser's native domains, or the fallback. */
export type Engine = "wayfern" | "fallback";
export interface ApiProfile {
id: string;
name: string;
browser: string;
version: string;
proxy_id?: string | null;
launch_hook?: string | null;
process_id?: number | null;
last_launch?: number | null;
release_type: string;
group_id?: string | null;
tags: string[];
is_running: boolean;
proxy_bypass_rules: string[];
vpn_id?: string | null;
extension_group_id?: string | null;
ephemeral: boolean;
temporary: boolean;
clear_on_close: boolean;
/** `"Disabled"`, `"Regular"` or `"Encrypted"`. */
sync_mode: string;
cloud_sync_enabled: boolean;
host_os?: string | null;
/** A profile from another OS can only ever run on a remote host of that OS. */
is_cross_os: boolean;
fingerprint_os?: string | null;
}
export interface ApiProfilesResponse {
profiles: ApiProfile[];
total: number;
}
export interface ApiProfileResponse {
profile: ApiProfile;
}
export interface ApiGroupResponse {
id: string;
name: string;
profile_count: number;
}
export interface ApiProxyResponse {
id: string;
name: string;
proxy_settings: ProxySettings;
}
export interface ApiVpnResponse {
id: string;
name: string;
/** Always `"WireGuard"`. */
vpn_type: string;
created_at: number;
last_used?: number | null;
}
export interface ApiVpnExportResponse {
id: string;
name: string;
vpn_type: string;
/** Raw, decrypted `.conf` content. Treat it as a secret. */
config_data: string;
}
export interface DownloadBrowserResponse {
browser: string;
version: string;
status: string;
}
export interface RunProfileResponse {
profile_id: string;
remote_debugging_port: number;
headless: boolean;
}
export interface RunRemoteResponse {
profile_id: string;
session_id: string;
/** Always the profile's own operating system. */
platform: string;
status: string;
}
export interface StopRemoteResponse {
session_id: string;
status: string;
billed_seconds: number;
}
export interface SetCloudSyncResponse {
profile_id: string;
mode: string;
remote_launchable: boolean;
remote_blocked_reason?: string | null;
}
export interface RemoteSessionState {
session_id: string;
profile_id?: string | null;
platform?: string | null;
/** `provisioning` | `ready` | `live` | `closed` | `error`. */
state: string;
cdp_ready?: boolean;
/** `interactive` or `cookie_bot`. */
kind?: string | null;
run_id?: string | null;
team_id?: string | null;
started_at?: string | null;
ended_at?: string | null;
close_reason?: string | null;
billed_seconds?: number | null;
}
export interface ApiRemoteSessionsResponse {
sessions: RemoteSessionState[];
}
export interface RemoteHoursBreakdown {
interactive_hours?: number;
bot_hours?: number;
}
export interface RemoteHoursMember {
user_id: string;
email: string;
role?: string | null;
used_hours?: number;
interactive_hours?: number;
bot_hours?: number;
}
export interface RemoteHoursQuota {
granted_hours: number;
remaining_hours: number;
used_hours?: number;
period_start?: string | null;
period_end?: string | null;
/** `user` or `team`. */
scope?: string | null;
team_id?: string | null;
seats?: number;
per_seat_hours?: number;
breakdown?: RemoteHoursBreakdown | null;
members?: RemoteHoursMember[];
}
export interface CookieBotSlot {
run_at_minute?: number;
days_mask?: number;
}
export interface CookieBotSchedule {
profile_id: string;
profile_name: string;
platform: string;
enabled: boolean;
run_at_minute: number;
days_mask: number;
/**
* Every time-of-day this enrolment fires. An older server sends only the
* mirrored `run_at_minute`/`days_mask` pair above, so an empty list means
* "fall back to the pair", never "fires at no time".
*/
slots?: CookieBotSlot[];
timezone: string;
preset: string;
template_id?: string | null;
max_minutes: number;
sites?: string[];
jitter_seconds?: number;
sync_enabled?: boolean;
encrypted_sync?: boolean;
has_proxy?: boolean;
proxy_remote_reachable?: boolean;
touch_fingerprint?: boolean;
sticky_exit?: boolean;
profile_state_at?: string | null;
/** Why tonight would be refused, or absent. */
blocked_by?: string | null;
next_run_at?: string | null;
last_run_at?: string | null;
last_run_id?: string | null;
owner_user_id?: string | null;
owner_email?: string | null;
updated_at?: string | null;
}
export interface CookieBotScheduleList {
schedules?: CookieBotSchedule[];
team_id?: string | null;
scope?: string | null;
}
export interface CookieBotConflict {
user_id: string;
email: string;
run_at_minute: number;
timezone: string;
days_mask: number;
enabled: boolean;
overlaps?: boolean;
}
export interface CookieBotScheduleSaved {
schedule: CookieBotSchedule;
conflicts?: CookieBotConflict[];
}
export interface CookieBotConflictCheck {
profile_id: string;
conflicts?: CookieBotConflict[];
}
export interface CookieBotScheduleDeleted {
profile_id: string;
deleted: boolean;
}
export interface CookieBotRun {
id: string;
profile_id: string;
profile_name?: string | null;
user_id?: string | null;
email?: string | null;
team_id?: string | null;
/** `schedule` or `manual`. */
trigger: string;
/** `pending` | `running` | `succeeded` | `partial` | `failed` | `skipped` | `cancelled`. */
status: string;
scheduled_for: string;
dispatch_after?: string | null;
started_at?: string | null;
ended_at?: string | null;
max_minutes?: number;
chunks_total?: number;
chunk_index?: number;
sites_total?: number;
sites_visited?: number;
sites_failed?: number;
consent_dismissed?: number;
billed_seconds?: number;
outcome_code?: string | null;
session_id?: string | null;
}
export interface CookieBotRunPage {
runs?: CookieBotRun[];
/** Keyset cursor; absent on the last page. */
next_before?: string | null;
}
export interface CookieBotRunStarted {
run: CookieBotRun;
session_id?: string | null;
}
export interface CookieBotPreset {
id: string;
typical_minutes?: number | null;
recommended?: boolean;
name?: string | null;
description?: string | null;
}
export interface CookieBotPresetList {
presets?: CookieBotPreset[];
default_preset?: string | null;
/** Whatever the server publishes; the app forwards it without narrowing. */
templates?: Record<string, unknown>[];
limits?: Record<string, unknown> | null;
}
export interface CookieBotUsageMember {
user_id: string;
email: string;
role?: string | null;
interactive_hours?: number;
bot_hours?: number;
used_hours?: number;
sessions?: number;
bot_runs?: number;
bot_runs_failed?: number;
}
export interface CookieBotUsageProfile {
profile_id: string;
profile_name?: string | null;
owner_email?: string | null;
bot_hours?: number;
runs?: number;
runs_failed?: number;
last_run_at?: string | null;
last_status?: string | null;
}
export interface CookieBotUsage {
period: string;
period_start?: string | null;
period_end?: string | null;
team_id?: string | null;
seats?: number;
granted_hours?: number;
used_hours?: number;
remaining_hours?: number;
members?: CookieBotUsageMember[];
profiles?: CookieBotUsageProfile[];
}
export interface BatchRunResult {
profile_id: string;
ok: boolean;
remote_debugging_port?: number | null;
error?: string | null;
}
export interface BatchRunResponse {
results: BatchRunResult[];
}
export interface BatchStopResult {
profile_id: string;
ok: boolean;
error?: string | null;
}
export interface BatchStopResponse {
results: BatchStopResult[];
}
/** One profile, one proxy. The distribution applies exactly these pairs. */
export interface ProxyPair {
profile_id: string;
proxy_id: string;
}
export interface ProxyAssignmentResult {
profile_id: string;
proxy_id: string;
ok: boolean;
/** A `{"code": ...}` payload when `ok` is false, otherwise null. */
error?: string | null;
}
export interface DistributeProxiesResponse {
results: ProxyAssignmentResult[];
}
export interface ImportCookiesResponse {
cookies_imported: number;
cookies_replaced: number;
errors: string[];
}
export interface ImportProxiesResponse {
imported_count: number;
skipped_count: number;
errors: string[];
proxies: ApiProxyResponse[];
}
export interface DetectedProfile {
browser: string;
mapped_browser: string;
name: string;
path: string;
description: string;
}
export interface DetectedProfilesResponse {
profiles: DetectedProfile[];
total: number;
}
export interface ImportProfileItem {
source_path: string;
/**
* The source browser family (`chromium`, `brave`, `edge`, ...). Load-bearing:
* it picks which keychain entry unlocks the source's cookies and passwords.
*/
browser_type?: string;
new_profile_name: string;
proxy_id?: string | null;
vpn_id?: string | null;
allow_running?: boolean | null;
}
export interface ProfileImportItemResult {
name: string;
source_path: string;
/** `"imported"` | `"skipped"` | `"failed"`. */
status: string;
profile_id?: string | null;
error?: string | null;
report?: Record<string, unknown> | null;
}
export interface ProfileImportBatchResult {
imported_count: number;
skipped_count: number;
failed_count: number;
results: ProfileImportItemResult[];
}
export interface Extension {
id: string;
name: string;
manifest_name?: string | null;
file_name: string;
file_type: string;
browser_compatibility: string[];
created_at: number;
updated_at: number;
sync_enabled?: boolean;
last_sync?: number | null;
version?: string | null;
description?: string | null;
author?: string | null;
homepage_url?: string | null;
/** `archive` or `unpacked`. */
source_kind: string;
/** Set when the extension is loaded from a folder in place. Never synced. */
linked_path?: string | null;
}
export interface ExtensionGroup {
id: string;
name: string;
extension_ids: string[];
created_at: number;
updated_at: number;
sync_enabled?: boolean;
last_sync?: number | null;
}
export interface LocatorAttribute {
name: string;
value: string;
}
/**
* How an element is named without a CSS selector.
*
* At least one property must be set. Keys are the browser's own camelCase; the
* app also accepts `name_contains` and `text_contains` on input, but a locator
* handed back by `agentPick` uses the spellings below, so reusing one verbatim
* is the reliable path.
*/
export interface LocatorDescription {
/** AX role token, matched case- and separator-insensitively. */
role?: string;
/** Computed accessible name, exact after whitespace collapse. */
name?: string;
nameContains?: string;
/** Visible text content, from the live layout. */
text?: string;
textContains?: string;
attributes?: LocatorAttribute[];
}
export interface LocatorBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface LocatorCandidate {
/** Absent on the fallback engine, which has no DOM agent behind it. */
backendNodeId?: number;
role: string;
name: string;
text: string;
/** Omitted, never blanked, for a control the page marked protected. */
value?: string;
url?: string;
/** Per-profile deterministic identifier for the node's structural position. */
signature: string;
attributes?: LocatorAttribute[];
bounds: LocatorBounds;
}
export interface LocatorResolution {
backendNodeId?: number;
/** Always 1: present so a caller can assert it rather than infer it. */
matchCount: number;
match: LocatorCandidate;
locator: LocatorDescription;
engine: Engine;
}
export interface PerceptionNode {
/** Short, stable, frame-qualified handle. */
id: string;
frameId: string;
role: string;
x: number;
y: number;
width: number;
height: number;
inViewport: boolean;
visible: boolean;
focused: boolean;
disabled: boolean;
parentId?: string;
name?: string;
text?: string;
value?: string;
/** `"true"`, `"false"` or `"mixed"`; absent for anything not checkable. */
checked?: string;
expanded?: boolean;
scrollable?: boolean;
scrollContainerId?: string;
}
export interface PerceptionFrame {
frameId: string;
url: string;
crossOrigin: boolean;
parentFrameId?: string;
}
export interface PerceptionStats {
totalNodes: number;
returnedNodes: number;
bytes: number;
elapsedMs: number;
framesVisited: number;
/** Frames whose renderer did not answer within the budget. */
framesFailed: number;
}
export interface PerceptionPage {
snapshotId: string;
nodes: PerceptionNode[];
frames: PerceptionFrame[];
/** Readable text for exactly the nodes returned. */
text: string;
truncated: boolean;
stats: PerceptionStats;
/** Present when `truncated`: pass it back to continue. */
cursor?: string;
engine: Engine;
}
export interface ExtractionField {
/** The key this column appears under in each row's values. */
key: string;
/** Evaluated inside each container; the first match wins. */
locator: LocatorDescription;
/** `"text"`, `"attribute"` or `"link"`. */
source: string;
/** Required when `source` is `"attribute"`. */
attribute?: string;
}
export interface ExtractionRow {
/** Global across pages. */
index: number;
/** Zero-based page this row came from. */
page: number;
values: Record<string, unknown>;
}
export interface Extraction {
rows: ExtractionRow[];
rowCount: number;
pageCount: number;
byteSize: number;
truncated: boolean;
/**
* `complete` | `no-container` | `no-next` | `page-cap` | `row-cap` |
* `byte-cap` | `time-budget`. A missing container is `no-container`, not an
* error.
*/
stopReason: string;
engine: Engine;
}
export interface PickedElement {
backendNodeId: number;
/** The smallest description that still resolves to this node. */
locator: LocatorDescription;
matchCount: number;
node: LocatorCandidate;
engine: Engine;
}
/** What a click did. Note the snake_case body and the `match` key. */
export interface AgentClick {
clicked: boolean;
match: LocatorCandidate;
engine: Engine;
/** Whether a page load followed the click. */
navigated: boolean;
}
/** What a typing call did. */
export interface AgentTyping {
typed: boolean;
characters: number;
/** Absent on the fallback engine, which does not count its own mistypes. */
corrections?: number;
duration_ms: number;
engine: Engine;
match: LocatorCandidate;
}
+109
View File
@@ -0,0 +1,109 @@
/** Where the token and the port come from, and in what order. */
import assert from "node:assert/strict";
import { test } from "node:test";
import { DEFAULT_HOST, DEFAULT_PORT, DonutClient, DonutError } from "../src/index.mts";
import { FakeDonut } from "./fake-donut.mts";
test("arguments are used as given", () => {
const client = new DonutClient({ token: "from-argument", port: 12345, env: {} });
assert.equal(client.token, "from-argument");
assert.equal(client.port, 12345);
assert.equal(client.host, DEFAULT_HOST);
assert.equal(client.baseUrl, "http://127.0.0.1:12345");
});
test("the environment fills in what was not passed", () => {
const client = new DonutClient({
env: { DONUT_API_TOKEN: "from-env", DONUT_API_PORT: "13579" },
});
assert.equal(client.token, "from-env");
assert.equal(client.port, 13579);
});
test("arguments win over the environment", () => {
const client = new DonutClient({
token: "from-argument",
port: 111,
env: { DONUT_API_TOKEN: "from-env", DONUT_API_PORT: "222" },
});
assert.equal(client.token, "from-argument");
assert.equal(client.port, 111);
});
test("the port falls back to the app default", () => {
const client = new DonutClient({ env: { DONUT_API_TOKEN: "t" } });
assert.equal(client.port, DEFAULT_PORT);
assert.equal(DEFAULT_PORT, 10108);
});
test("a baseUrl overrides host and port", () => {
const client = new DonutClient({
baseUrl: "http://127.0.0.1:9999/donut",
token: "t",
env: { DONUT_API_PORT: "222" },
});
assert.equal(client.port, 9999);
assert.equal(client.baseUrl, "http://127.0.0.1:9999/donut");
});
test("a baseUrl prefix is kept on every path", async () => {
const fake = await new FakeDonut().start();
try {
const client = new DonutClient({
baseUrl: `http://127.0.0.1:${fake.port}/donut`,
token: "t",
timeoutMs: 5_000,
env: {},
});
await client.listProfiles();
assert.equal(fake.last.path, "/donut/v1/profiles");
} finally {
await fake.stop();
}
});
test("an unusable port in the environment is reported", () => {
assert.throws(
() => new DonutClient({ env: { DONUT_API_TOKEN: "t", DONUT_API_PORT: "not-a-number" } }),
/DONUT_API_PORT/,
);
});
test("an unsupported scheme is refused", () => {
assert.throws(
() => new DonutClient({ baseUrl: "ftp://127.0.0.1:9999", token: "t", env: {} }),
DonutError,
);
});
test("the websocket address is built from the same base", () => {
const client = new DonutClient({ token: "t", port: 10108, env: {} });
assert.equal(
client.remoteSessionCdpUrl("s 1"),
"ws://127.0.0.1:10108/v1/remote-sessions/s%201/cdp",
);
});
test("an https base gives a wss websocket address", () => {
const client = new DonutClient({ baseUrl: "https://127.0.0.1:8443", token: "t", env: {} });
assert.equal(
client.remoteSessionCdpUrl("s1"),
"wss://127.0.0.1:8443/v1/remote-sessions/s1/cdp",
);
});
test("a supplied fetch is the one that is used", async () => {
const seen: string[] = [];
const client = new DonutClient({
token: "t",
env: {},
fetch: async (input) => {
seen.push(String(input));
return new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } });
},
});
assert.deepEqual(await client.listTags(), []);
assert.deepEqual(seen, ["http://127.0.0.1:10108/v1/tags"]);
});
+97
View File
@@ -0,0 +1,97 @@
/**
* The SDK cannot silently drift from the app's API.
*
* `sdk/api-paths.json` is generated from `src-tauri/src/api_server.rs` and
* lists every operation the desktop app publishes. These tests hold it against
* the SDK's own table in both directions, so a new endpoint in the app fails
* here until it is wrapped or deliberately omitted with a reason.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { DonutClient, OMITTED, OPERATIONS } from "../src/index.mts";
const SNAPSHOT = fileURLToPath(new URL("../../api-paths.json", import.meta.url));
interface Snapshot {
source: string;
operation_count: number;
operations: { operation_id: string; method: string; path: string }[];
}
function snapshot(): Snapshot {
return JSON.parse(readFileSync(SNAPSHOT, "utf8")) as Snapshot;
}
function published(): Set<string> {
return new Set(snapshot().operations.map((entry) => `${entry.method} ${entry.path}`));
}
test("the snapshot is readable and not empty", () => {
const document = snapshot();
assert.equal(document.source, "src-tauri/src/api_server.rs");
assert.equal(document.operation_count, document.operations.length);
assert.ok(document.operation_count > 0);
assert.equal(
published().size,
document.operation_count,
"the app has two identical operations",
);
});
test("every published operation is wrapped or omitted", () => {
const known = new Set([...OPERATIONS.keys(), ...OMITTED.keys()]);
const missing = [...published()].filter((key) => !known.has(key)).sort();
assert.deepEqual(
missing,
[],
`the app publishes operations this SDK does not handle: ${missing.join(", ")}. ` +
"Wrap each one, or add it to OMITTED with a reason.",
);
});
test("the SDK claims nothing the app does not publish", () => {
const live = published();
const stale = [...OPERATIONS.keys(), ...OMITTED.keys()].filter((key) => !live.has(key)).sort();
assert.deepEqual(
stale,
[],
`this SDK handles operations the app no longer publishes: ${stale.join(", ")}. ` +
"Regenerate the snapshot with sdk/tools/extract-api-paths.py, then drop or fix each entry.",
);
});
test("an operation is either wrapped or omitted but not both", () => {
const both = [...OPERATIONS.keys()].filter((key) => OMITTED.has(key)).sort();
assert.deepEqual(both, [], `listed twice: ${both.join(", ")}`);
});
test("every omission gives a reason", () => {
for (const [operation, reason] of OMITTED) {
assert.ok(reason.trim().length > 40, `${operation} is omitted without a real reason`);
}
});
test("every wrapped operation names a real method", () => {
const prototype = DonutClient.prototype as unknown as Record<string, unknown>;
for (const [operation, name] of OPERATIONS) {
assert.equal(
typeof prototype[name],
"function",
`${operation} names ${name}, which is not a method`,
);
}
});
test("no two operations share a method", () => {
const names = [...OPERATIONS.values()];
const duplicates = [...new Set(names.filter((name, index) => names.indexOf(name) !== index))];
assert.deepEqual(
duplicates,
[],
`one method is claimed by several operations: ${duplicates.join(", ")}`,
);
});
+219
View File
@@ -0,0 +1,219 @@
/** Each status the app documents throws its own error. */
import assert from "node:assert/strict";
import { test } from "node:test";
import {
BadGateway,
Conflict,
DonutApiError,
DonutClient,
DonutConnectionError,
DonutError,
Forbidden,
NotFound,
PaymentRequired,
RateLimited,
RequestTimeout,
ServerError,
ServiceUnavailable,
Unauthorized,
ValidationError,
} from "../src/index.mts";
import { FakeDonut } from "./fake-donut.mts";
import { withClient } from "./support.mts";
const STATUS_TO_ERROR: [number, new (...args: never[]) => DonutApiError][] = [
[400, ValidationError],
[401, Unauthorized],
[402, PaymentRequired],
[403, Forbidden],
[404, NotFound],
[408, RequestTimeout],
[409, Conflict],
[429, RateLimited],
[500, ServerError],
[502, BadGateway],
[503, ServiceUnavailable],
];
for (const [status, expected] of STATUS_TO_ERROR) {
test(`${status} maps to ${expected.name}`, async () => {
await withClient(async (client, fake) => {
fake.enqueueError(status, "something went wrong");
const thrown = await client.listProfiles().then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof expected, `expected ${expected.name}, got ${String(thrown)}`);
assert.equal(thrown.status, status);
assert.equal(thrown.body, "something went wrong");
assert.equal(thrown.method, "GET");
assert.equal(thrown.path, "/v1/profiles");
});
});
}
test("every error is a DonutError", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(404, "PROFILE_NOT_FOUND");
await assert.rejects(client.getProfile("nope"), DonutError);
});
});
test("the five hundreds share one base", async () => {
await withClient(async (client, fake) => {
for (const status of [500, 502, 503]) {
fake.enqueueError(status, "upstream");
await assert.rejects(client.listProfiles(), ServerError);
}
});
});
test("rate limited carries retryAfter", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(429, "automation request rate limit exceeded", { "Retry-After": "42" });
const thrown = await client.runProfile("p1").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof RateLimited);
assert.equal(thrown.retryAfter, 42);
});
});
test("rate limited without the header is still thrown", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(429, "slow down");
const thrown = await client.runProfile("p1").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof RateLimited);
assert.equal(thrown.retryAfter, null);
});
});
test("an unreadable Retry-After does not break the error", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(429, "slow down", { "Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT" });
const thrown = await client.runProfile("p1").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof RateLimited);
assert.equal(thrown.retryAfter, null);
});
});
test("a structured code body is decoded", async () => {
// The app shares `{"code": ...}` strings with its own frontend.
await withClient(async (client, fake) => {
fake.enqueueError(400, JSON.stringify({ code: "NAME_CANNOT_BE_EMPTY" }));
const thrown = await client.createGroup("").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof ValidationError);
assert.equal(thrown.code, "NAME_CANNOT_BE_EMPTY");
assert.deepEqual(thrown.params, {});
});
});
test("a structured code body keeps its params", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(
409,
JSON.stringify({ code: "PROFILE_LOCKED_BY_MEMBER", params: { n: "5" } }),
);
const thrown = await client.runProfile("p1").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof Conflict);
assert.equal(thrown.code, "PROFILE_LOCKED_BY_MEMBER");
assert.deepEqual(thrown.params, { n: "5" });
});
});
test("a plain text body leaves code unset", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(400, "invalid browser");
const thrown = await client.createProfile({ name: "x", browser: "chromium" }).then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof ValidationError);
assert.equal(thrown.code, null);
assert.equal(thrown.body, "invalid browser");
});
});
test("an undocumented status still throws something catchable", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(418, "teapot");
const thrown = await client.listProfiles().then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof DonutApiError);
assert.equal(thrown.status, 418);
});
});
test("an undocumented server status is a ServerError", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(504, "gateway timeout");
await assert.rejects(client.listProfiles(), ServerError);
});
});
test("the message names the call", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(404, "Profile not found");
const thrown = await client.getProfile("missing").then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof NotFound);
assert.match(thrown.message, /404/);
assert.match(thrown.message, /GET \/v1\/profiles\/missing/);
});
});
test("errors keep their class name", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(404, "gone");
const thrown = await client.listProfiles().then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof NotFound);
assert.equal(thrown.name, "NotFound");
});
});
test("an unreachable app is not an API error", async () => {
const fake = await new FakeDonut().start();
const port = fake.port;
await fake.stop();
const client = new DonutClient({ token: "t", port, timeoutMs: 2_000, env: {} });
const thrown = await client.listProfiles().then(
() => null,
(error: unknown) => error,
);
assert.ok(thrown instanceof DonutConnectionError);
assert.match(thrown.message, /Local API/);
});
test("a missing token fails before any request", () => {
assert.throws(() => new DonutClient({ env: {} }), /DONUT_API_TOKEN/);
});
test("a non-JSON answer is reported as such", async () => {
await withClient(async (client, fake) => {
fake.enqueueRaw(200, "<html>nope</html>");
await assert.rejects(client.listProfiles(), /not\s+JSON/);
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* A stand-in for the desktop app's local REST API.
*
* It records what the client sent, byte for byte, and answers with whatever
* the test queued. Nothing here reaches the network: it binds an ephemeral
* loopback port and is torn down with the test.
*/
import { createServer } from "node:http";
import type { IncomingMessage, Server, ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
export interface RecordedRequest {
method: string;
target: string;
path: string;
query: Record<string, string>;
headers: Record<string, string>;
rawBody: string;
json: unknown;
}
export interface QueuedResponse {
status: number;
body: string;
headers: Record<string, string>;
contentType: string;
}
export class FakeDonut {
requests: RecordedRequest[] = [];
responses: QueuedResponse[] = [];
#server: Server | undefined = undefined;
enqueueJson(payload: unknown, status = 200): void {
this.responses.push({
status,
body: JSON.stringify(payload),
headers: {},
contentType: "application/json",
});
}
enqueueEmpty(status = 204): void {
this.responses.push({ status, body: "", headers: {}, contentType: "application/json" });
}
enqueueError(status: number, body = "", headers: Record<string, string> = {}): void {
this.responses.push({ status, body, headers, contentType: "text/plain" });
}
enqueueRaw(status: number, body: string, contentType = "text/html"): void {
this.responses.push({ status, body, headers: {}, contentType });
}
get port(): number {
if (this.#server === undefined) {
throw new Error("the fake server is not running");
}
return (this.#server.address() as AddressInfo).port;
}
get last(): RecordedRequest {
const request = this.requests.at(-1);
if (request === undefined) {
throw new Error("the client sent nothing");
}
return request;
}
async start(): Promise<this> {
const server = createServer((incoming: IncomingMessage, outgoing: ServerResponse) => {
const chunks: Buffer[] = [];
incoming.on("data", (chunk: Buffer) => chunks.push(chunk));
incoming.on("end", () => {
const rawBody = Buffer.concat(chunks).toString("utf8");
const url = new URL(incoming.url ?? "/", "http://127.0.0.1");
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(incoming.headers)) {
headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : (value ?? "");
}
this.requests.push({
method: incoming.method ?? "",
target: incoming.url ?? "",
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
headers,
rawBody,
json: rawBody === "" ? null : JSON.parse(rawBody),
});
const queued = this.responses.shift() ?? {
status: 200,
body: "{}",
headers: {},
contentType: "application/json",
};
for (const [name, value] of Object.entries(queued.headers)) {
outgoing.setHeader(name, value);
}
if (queued.body !== "") {
outgoing.setHeader("Content-Type", queued.contentType);
}
outgoing.writeHead(queued.status);
outgoing.end(queued.body);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
this.#server = server;
return this;
}
async stop(): Promise<void> {
const server = this.#server;
if (server === undefined) {
return;
}
this.#server = undefined;
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
}
/** Start a fake server, hand it to `work`, and always shut it down again. */
export async function withFakeDonut<T>(work: (fake: FakeDonut) => Promise<T>): Promise<T> {
const fake = await new FakeDonut().start();
try {
return await work(fake);
} finally {
await fake.stop();
}
}
+768
View File
@@ -0,0 +1,768 @@
/**
* Every client method sends exactly the request the app documents.
*
* The table below is the whole public surface. Each row names a method, the
* arguments to call it with, and the request that must appear on the wire: the
* verb, the concrete path, the query string and the JSON body. `operation` is
* the path template the app publishes, which ties this file to
* `OPERATIONS` and, through it, to `sdk/api-paths.json`.
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import { OPERATIONS } from "../src/index.mts";
import { withClient } from "./support.mts";
interface Case {
method: string;
args: unknown[];
verb: string;
path: string;
body: unknown;
query?: Record<string, string>;
operation: string;
}
const LOCATOR = { role: "button", name: "Sign in" };
const CASES: Case[] = [
// -- profiles ------------------------------------------------------------
{
method: "listProfiles",
args: [],
verb: "GET",
path: "/v1/profiles",
body: null,
operation: "GET /v1/profiles",
},
{
method: "getProfile",
args: ["p1"],
verb: "GET",
path: "/v1/profiles/p1",
body: null,
operation: "GET /v1/profiles/{id}",
},
{
method: "createProfile",
args: [{ name: "Shopper", browser: "wayfern", tags: ["eu"], ephemeral: true }],
verb: "POST",
path: "/v1/profiles",
body: { name: "Shopper", browser: "wayfern", tags: ["eu"], ephemeral: true },
operation: "POST /v1/profiles",
},
{
method: "createProfile",
args: [{ name: "Bare", browser: "wayfern", version: undefined }],
verb: "POST",
path: "/v1/profiles",
body: { name: "Bare", browser: "wayfern" },
operation: "POST /v1/profiles",
},
{
method: "updateProfile",
args: ["p1", { name: "Renamed", proxy_id: "", clear_on_close: false }],
verb: "PUT",
path: "/v1/profiles/p1",
body: { name: "Renamed", proxy_id: "", clear_on_close: false },
operation: "PUT /v1/profiles/{id}",
},
{
method: "deleteProfile",
args: ["p1"],
verb: "DELETE",
path: "/v1/profiles/p1",
body: null,
operation: "DELETE /v1/profiles/{id}",
},
{
method: "runProfile",
args: ["p1", { url: "https://example.com", headless: true }],
verb: "POST",
path: "/v1/profiles/p1/run",
body: { url: "https://example.com", headless: true },
operation: "POST /v1/profiles/{id}/run",
},
{
method: "runProfileRemote",
args: ["p1", { url: "https://example.com" }],
verb: "POST",
path: "/v1/profiles/p1/run-remote",
body: { url: "https://example.com" },
operation: "POST /v1/profiles/{id}/run-remote",
},
{
method: "setProfileCloudSync",
args: ["p1", "Regular"],
verb: "POST",
path: "/v1/profiles/p1/cloud-sync",
body: { mode: "Regular" },
operation: "POST /v1/profiles/{id}/cloud-sync",
},
{
method: "openUrl",
args: ["p1", "https://example.com/page"],
verb: "POST",
path: "/v1/profiles/p1/open-url",
body: { url: "https://example.com/page" },
operation: "POST /v1/profiles/{id}/open-url",
},
{
method: "killProfile",
args: ["p1"],
verb: "POST",
path: "/v1/profiles/p1/kill",
body: null,
operation: "POST /v1/profiles/{id}/kill",
},
{
method: "batchRunProfiles",
args: [["p1", "p2"], { headless: false }],
verb: "POST",
path: "/v1/profiles/batch/run",
body: { profile_ids: ["p1", "p2"], headless: false },
operation: "POST /v1/profiles/batch/run",
},
{
method: "batchStopProfiles",
args: [["p1", "p2"]],
verb: "POST",
path: "/v1/profiles/batch/stop",
body: { profile_ids: ["p1", "p2"] },
operation: "POST /v1/profiles/batch/stop",
},
{
method: "distributeProxies",
args: [
[
{ profile_id: "p1", proxy_id: "x1" },
{ profile_id: "p2", proxy_id: "x2" },
],
],
verb: "POST",
path: "/v1/profiles/distribute-proxies",
body: {
pairs: [
{ profile_id: "p1", proxy_id: "x1" },
{ profile_id: "p2", proxy_id: "x2" },
],
},
operation: "POST /v1/profiles/distribute-proxies",
},
{
method: "detectImportProfiles",
args: [{ folder: "/Users/x/Chrome" }],
verb: "GET",
path: "/v1/profiles/import/detect",
body: null,
query: { folder: "/Users/x/Chrome" },
operation: "GET /v1/profiles/import/detect",
},
{
method: "detectImportProfiles",
args: [],
verb: "GET",
path: "/v1/profiles/import/detect",
body: null,
operation: "GET /v1/profiles/import/detect",
},
{
method: "importProfiles",
args: [
[{ source_path: "/tmp/src", new_profile_name: "Imported" }],
{ duplicate_strategy: "skip" },
],
verb: "POST",
path: "/v1/profiles/import",
body: {
items: [{ source_path: "/tmp/src", new_profile_name: "Imported" }],
duplicate_strategy: "skip",
},
operation: "POST /v1/profiles/import",
},
{
method: "importProfileCookies",
args: ["p1", "[]"],
verb: "POST",
path: "/v1/profiles/p1/cookies/import",
body: { content: "[]" },
operation: "POST /v1/profiles/{id}/cookies/import",
},
// -- agent ---------------------------------------------------------------
{
method: "agentPerceive",
args: ["p1", { viewport_only: true, max_bytes: 2048 }],
verb: "POST",
path: "/v1/profiles/p1/agent/perceive",
body: { viewport_only: true, max_bytes: 2048 },
operation: "POST /v1/profiles/{id}/agent/perceive",
},
{
method: "agentPerceive",
args: ["p1"],
verb: "POST",
path: "/v1/profiles/p1/agent/perceive",
body: {},
operation: "POST /v1/profiles/{id}/agent/perceive",
},
{
method: "agentResolveLocator",
args: ["p1", { locator: LOCATOR, candidate_limit: 5 }],
verb: "POST",
path: "/v1/profiles/p1/agent/resolve-locator",
body: { locator: LOCATOR, candidate_limit: 5 },
operation: "POST /v1/profiles/{id}/agent/resolve-locator",
},
{
method: "agentClick",
args: ["p1", { locator: LOCATOR, button: "right", click_count: 2 }],
verb: "POST",
path: "/v1/profiles/p1/agent/click",
body: { locator: LOCATOR, button: "right", click_count: 2 },
operation: "POST /v1/profiles/{id}/agent/click",
},
{
method: "agentType",
args: ["p1", { locator: LOCATOR, text: "hello", clear_first: false, wpm: 55 }],
verb: "POST",
path: "/v1/profiles/p1/agent/type",
body: { locator: LOCATOR, text: "hello", clear_first: false, wpm: 55 },
operation: "POST /v1/profiles/{id}/agent/type",
},
{
method: "agentExtract",
args: [
"p1",
{
container: { role: "listitem" },
field_map: [{ key: "title", locator: { role: "heading" }, source: "text" }],
max_pages: 3,
},
],
verb: "POST",
path: "/v1/profiles/p1/agent/extract",
body: {
container: { role: "listitem" },
field_map: [{ key: "title", locator: { role: "heading" }, source: "text" }],
max_pages: 3,
},
operation: "POST /v1/profiles/{id}/agent/extract",
},
{
method: "agentPick",
args: ["p1", { timeout_ms: 15000 }],
verb: "POST",
path: "/v1/profiles/p1/agent/pick",
body: { timeout_ms: 15000 },
operation: "POST /v1/profiles/{id}/agent/pick",
},
// -- remote sessions -----------------------------------------------------
{
method: "listRemoteSessions",
args: [],
verb: "GET",
path: "/v1/remote-sessions",
body: null,
operation: "GET /v1/remote-sessions",
},
{
method: "getRemoteSession",
args: ["s1"],
verb: "GET",
path: "/v1/remote-sessions/s1",
body: null,
operation: "GET /v1/remote-sessions/{id}",
},
{
method: "stopRemoteSession",
args: ["s1"],
verb: "DELETE",
path: "/v1/remote-sessions/s1",
body: null,
operation: "DELETE /v1/remote-sessions/{id}",
},
{
method: "getRemoteHours",
args: [],
verb: "GET",
path: "/v1/remote-hours",
body: null,
operation: "GET /v1/remote-hours",
},
// -- cookie bot ----------------------------------------------------------
{
method: "listCookieBotSchedules",
args: [{ scope: "team" }],
verb: "GET",
path: "/v1/cookie-bot/schedules",
body: null,
query: { scope: "team" },
operation: "GET /v1/cookie-bot/schedules",
},
{
method: "getCookieBotSchedule",
args: ["p1"],
verb: "GET",
path: "/v1/cookie-bot/schedules/p1",
body: null,
operation: "GET /v1/cookie-bot/schedules/{profile_id}",
},
{
method: "setCookieBotSchedule",
args: [
"p1",
{
enabled: true,
run_at_minute: 120,
days_mask: 31,
timezone: "Europe/Berlin",
preset: "steady",
max_minutes: 45,
sites: ["https://example.com"],
acknowledge_conflict: true,
},
],
verb: "PUT",
path: "/v1/cookie-bot/schedules/p1",
body: {
enabled: true,
run_at_minute: 120,
days_mask: 31,
timezone: "Europe/Berlin",
preset: "steady",
max_minutes: 45,
sites: ["https://example.com"],
acknowledge_conflict: true,
},
operation: "PUT /v1/cookie-bot/schedules/{profile_id}",
},
{
method: "deleteCookieBotSchedule",
args: ["p1"],
verb: "DELETE",
path: "/v1/cookie-bot/schedules/p1",
body: null,
operation: "DELETE /v1/cookie-bot/schedules/{profile_id}",
},
{
method: "getCookieBotConflicts",
args: ["p1", { run_at_minute: 90, timezone: "UTC", days_mask: 7 }],
verb: "GET",
path: "/v1/cookie-bot/conflicts",
body: null,
query: { profile_id: "p1", run_at_minute: "90", timezone: "UTC", days_mask: "7" },
operation: "GET /v1/cookie-bot/conflicts",
},
{
method: "listCookieBotRuns",
args: [{ profile_id: "p1", limit: 10, before: "cursor-1" }],
verb: "GET",
path: "/v1/cookie-bot/runs",
body: null,
query: { profile_id: "p1", limit: "10", before: "cursor-1" },
operation: "GET /v1/cookie-bot/runs",
},
{
method: "startCookieBotRun",
args: [{ profile_id: "p1", max_minutes: 30 }],
verb: "POST",
path: "/v1/cookie-bot/runs",
body: { profile_id: "p1", max_minutes: 30 },
operation: "POST /v1/cookie-bot/runs",
},
{
method: "cancelCookieBotRun",
args: ["r1"],
verb: "DELETE",
path: "/v1/cookie-bot/runs/r1",
body: null,
operation: "DELETE /v1/cookie-bot/runs/{run_id}",
},
{
method: "listCookieBotPresets",
args: [],
verb: "GET",
path: "/v1/cookie-bot/presets",
body: null,
operation: "GET /v1/cookie-bot/presets",
},
{
method: "getCookieBotUsage",
args: [{ period: "2026-08" }],
verb: "GET",
path: "/v1/cookie-bot/usage",
body: null,
query: { period: "2026-08" },
operation: "GET /v1/cookie-bot/usage",
},
// -- groups and tags -----------------------------------------------------
{
method: "listGroups",
args: [],
verb: "GET",
path: "/v1/groups",
body: null,
operation: "GET /v1/groups",
},
{
method: "getGroup",
args: ["g1"],
verb: "GET",
path: "/v1/groups/g1",
body: null,
operation: "GET /v1/groups/{id}",
},
{
method: "createGroup",
args: ["Retail"],
verb: "POST",
path: "/v1/groups",
body: { name: "Retail" },
operation: "POST /v1/groups",
},
{
method: "updateGroup",
args: ["g1", "Retail EU"],
verb: "PUT",
path: "/v1/groups/g1",
body: { name: "Retail EU" },
operation: "PUT /v1/groups/{id}",
},
{
method: "deleteGroup",
args: ["g1"],
verb: "DELETE",
path: "/v1/groups/g1",
body: null,
operation: "DELETE /v1/groups/{id}",
},
{
method: "listTags",
args: [],
verb: "GET",
path: "/v1/tags",
body: null,
operation: "GET /v1/tags",
},
// -- proxies -------------------------------------------------------------
{
method: "listProxies",
args: [],
verb: "GET",
path: "/v1/proxies",
body: null,
operation: "GET /v1/proxies",
},
{
method: "getProxy",
args: ["x1"],
verb: "GET",
path: "/v1/proxies/x1",
body: null,
operation: "GET /v1/proxies/{id}",
},
{
method: "createProxy",
args: [{ name: "EU", proxy_settings: { proxy_type: "http", host: "h", port: 8080 } }],
verb: "POST",
path: "/v1/proxies",
body: { name: "EU", proxy_settings: { proxy_type: "http", host: "h", port: 8080 } },
operation: "POST /v1/proxies",
},
{
method: "updateProxy",
args: ["x1", { name: "EU 2" }],
verb: "PUT",
path: "/v1/proxies/x1",
body: { name: "EU 2" },
operation: "PUT /v1/proxies/{id}",
},
{
method: "deleteProxy",
args: ["x1"],
verb: "DELETE",
path: "/v1/proxies/x1",
body: null,
operation: "DELETE /v1/proxies/{id}",
},
{
method: "importProxies",
args: [{ format: "txt", content: "h:1:u:p", name_prefix: "EU" }],
verb: "POST",
path: "/v1/proxies/import",
body: { format: "txt", content: "h:1:u:p", name_prefix: "EU" },
operation: "POST /v1/proxies/import",
},
// -- vpns ----------------------------------------------------------------
{
method: "listVpns",
args: [],
verb: "GET",
path: "/v1/vpns",
body: null,
operation: "GET /v1/vpns",
},
{
method: "getVpn",
args: ["v1"],
verb: "GET",
path: "/v1/vpns/v1",
body: null,
operation: "GET /v1/vpns/{id}",
},
{
method: "exportVpn",
args: ["v1"],
verb: "GET",
path: "/v1/vpns/v1/export",
body: null,
operation: "GET /v1/vpns/{id}/export",
},
{
method: "importVpn",
args: [{ content: "[Interface]", filename: "eu.conf" }],
verb: "POST",
path: "/v1/vpns/import",
body: { content: "[Interface]", filename: "eu.conf" },
operation: "POST /v1/vpns/import",
},
{
method: "createVpn",
args: [{ name: "EU", vpn_type: "WireGuard", config_data: "[Interface]" }],
verb: "POST",
path: "/v1/vpns",
body: { name: "EU", vpn_type: "WireGuard", config_data: "[Interface]" },
operation: "POST /v1/vpns",
},
{
method: "updateVpn",
args: ["v1", "EU 2"],
verb: "PUT",
path: "/v1/vpns/v1",
body: { name: "EU 2" },
operation: "PUT /v1/vpns/{id}",
},
{
method: "deleteVpn",
args: ["v1"],
verb: "DELETE",
path: "/v1/vpns/v1",
body: null,
operation: "DELETE /v1/vpns/{id}",
},
// -- extensions ----------------------------------------------------------
{
method: "listExtensions",
args: [],
verb: "GET",
path: "/v1/extensions",
body: null,
operation: "GET /v1/extensions",
},
{
method: "getExtension",
args: ["e1"],
verb: "GET",
path: "/v1/extensions/e1",
body: null,
operation: "GET /v1/extensions/{id}",
},
{
method: "createExtension",
args: [{ name: "Blocker", file_name: "b.crx", file_data_base64: "AAAA" }],
verb: "POST",
path: "/v1/extensions",
body: { name: "Blocker", file_name: "b.crx", file_data_base64: "AAAA" },
operation: "POST /v1/extensions",
},
{
method: "updateExtension",
args: ["e1", { name: "Blocker 2", link: true }],
verb: "PUT",
path: "/v1/extensions/e1",
body: { name: "Blocker 2", link: true },
operation: "PUT /v1/extensions/{id}",
},
{
method: "deleteExtension",
args: ["e1"],
verb: "DELETE",
path: "/v1/extensions/e1",
body: null,
operation: "DELETE /v1/extensions/{id}",
},
{
method: "listExtensionGroups",
args: [],
verb: "GET",
path: "/v1/extension-groups",
body: null,
operation: "GET /v1/extension-groups",
},
{
method: "getExtensionGroup",
args: ["eg1"],
verb: "GET",
path: "/v1/extension-groups/eg1",
body: null,
operation: "GET /v1/extension-groups/{id}",
},
{
method: "createExtensionGroup",
args: ["Adblock set"],
verb: "POST",
path: "/v1/extension-groups",
body: { name: "Adblock set" },
operation: "POST /v1/extension-groups",
},
{
method: "updateExtensionGroup",
args: ["eg1", { extension_ids: ["e1", "e2"] }],
verb: "PUT",
path: "/v1/extension-groups/eg1",
body: { extension_ids: ["e1", "e2"] },
operation: "PUT /v1/extension-groups/{id}",
},
{
method: "deleteExtensionGroup",
args: ["eg1"],
verb: "DELETE",
path: "/v1/extension-groups/eg1",
body: null,
operation: "DELETE /v1/extension-groups/{id}",
},
{
method: "addExtensionToGroup",
args: ["eg1", "e1"],
verb: "POST",
path: "/v1/extension-groups/eg1/extensions/e1",
body: null,
operation: "POST /v1/extension-groups/{id}/extensions/{extension_id}",
},
{
method: "removeExtensionFromGroup",
args: ["eg1", "e1"],
verb: "DELETE",
path: "/v1/extension-groups/eg1/extensions/e1",
body: null,
operation: "DELETE /v1/extension-groups/{id}/extensions/{extension_id}",
},
// -- browsers ------------------------------------------------------------
{
method: "downloadBrowser",
args: [{ browser: "wayfern", version: "152.0.1" }],
verb: "POST",
path: "/v1/browsers/download",
body: { browser: "wayfern", version: "152.0.1" },
operation: "POST /v1/browsers/download",
},
{
method: "listBrowserVersions",
args: ["wayfern"],
verb: "GET",
path: "/v1/browsers/wayfern/versions",
body: null,
operation: "GET /v1/browsers/{browser}/versions",
},
{
method: "isBrowserDownloaded",
args: ["wayfern", "152.0.1"],
verb: "GET",
path: "/v1/browsers/wayfern/versions/152.0.1/downloaded",
body: null,
operation: "GET /v1/browsers/{browser}/versions/{version}/downloaded",
},
];
for (const [index, expected] of CASES.entries()) {
test(`${expected.method} sends the documented request [${index}]`, async () => {
await withClient(async (client, fake) => {
const callable = (client as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>)[
expected.method
];
assert.equal(typeof callable, "function", `${expected.method} is not a method`);
await callable.call(client, ...expected.args);
const sent = fake.last;
assert.equal(sent.method, expected.verb);
assert.equal(sent.path, expected.path);
assert.deepEqual(sent.query, expected.query ?? {});
assert.deepEqual(sent.json, expected.body);
assert.equal(OPERATIONS.get(expected.operation), expected.method);
});
});
}
test("every wrapped operation has a request test", () => {
const covered = new Set(CASES.map((entry) => entry.method));
const missing = [...OPERATIONS.values()].filter((name) => !covered.has(name)).sort();
assert.deepEqual(missing, [], `these wrapped operations have no request test: ${missing}`);
});
test("the token travels as a bearer header", async () => {
await withClient(async (client, fake) => {
await client.listProfiles();
assert.equal(fake.last.headers.authorization, "Bearer test-token-abc123");
assert.equal(fake.last.headers.accept, "application/json");
assert.equal(
fake.last.headers["content-type"],
undefined,
"a GET must not claim to carry JSON",
);
});
});
test("a body is sent as JSON", async () => {
await withClient(async (client, fake) => {
await client.createGroup("Retail");
assert.equal(fake.last.headers["content-type"], "application/json");
assert.equal(fake.last.rawBody, '{"name":"Retail"}');
});
});
test("path ids are escaped", async () => {
await withClient(async (client, fake) => {
await client.getProfile("a/b c?d");
assert.equal(fake.last.path, "/v1/profiles/a%2Fb%20c%3Fd");
});
});
test("undefined arguments are left out of the body", async () => {
await withClient(async (client, fake) => {
await client.updateProfile("p1", { name: "Only this", version: undefined });
assert.deepEqual(fake.last.json, { name: "Only this" });
});
});
test("an empty string still reaches the app", async () => {
// `proxy_id: ""` is how the app is told to detach a proxy, so it must survive.
await withClient(async (client, fake) => {
await client.updateProfile("p1", { proxy_id: "" });
assert.deepEqual(fake.last.json, { proxy_id: "" });
});
});
test("a no-content answer becomes undefined", async () => {
await withClient(async (client, fake) => {
fake.enqueueEmpty(204);
assert.equal(await client.deleteProfile("p1"), undefined);
});
});
test("a JSON answer is returned as sent", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson({ profiles: [{ id: "p1", name: "Shopper" }], total: 1 });
assert.deepEqual(await client.listProfiles(), {
profiles: [{ id: "p1", name: "Shopper" }],
total: 1,
});
});
});
test("a bare boolean answer is returned", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(true);
assert.equal(await client.isBrowserDownloaded("wayfern", "152.0.1"), true);
});
});
+134
View File
@@ -0,0 +1,134 @@
/** `withProfile` launches, hands over the CDP endpoint, and stops. */
import assert from "node:assert/strict";
import { test } from "node:test";
import { Conflict, DonutError, RunSession } from "../src/index.mts";
import { withClient } from "./support.mts";
const RUN_BODY = { profile_id: "p1", remote_debugging_port: 9222, headless: true };
test("the callback gets the CDP endpoint", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueEmpty(204);
const seen = await client.withProfile(
"p1",
{ url: "https://example.com", headless: true },
(session) => {
assert.ok(session instanceof RunSession);
assert.equal(session.remoteDebuggingPort, 9222);
assert.equal(session.headless, true);
assert.equal(session.cdpUrl, "http://127.0.0.1:9222");
assert.deepEqual(session.response, RUN_BODY);
return session.cdpUrl;
},
);
assert.equal(seen, "http://127.0.0.1:9222");
assert.deepEqual(
fake.requests.map((sent) => `${sent.method} ${sent.path}`),
["POST /v1/profiles/p1/run", "POST /v1/profiles/p1/kill"],
);
assert.deepEqual(fake.requests[0]?.json, { url: "https://example.com", headless: true });
});
});
test("the browser is stopped when the callback throws", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueEmpty(204);
await assert.rejects(
client.withProfile("p1", {}, () => {
throw new RangeError("the body failed");
}),
RangeError,
);
assert.deepEqual(
fake.requests.map((sent) => sent.path),
["/v1/profiles/p1/run", "/v1/profiles/p1/kill"],
);
});
});
test("a failed stop never hides why the callback failed", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueError(409, "PROFILE_LOCKED_ELSEWHERE");
let captured: RunSession | undefined;
await assert.rejects(
client.withProfile("p1", {}, (session) => {
captured = session;
throw new RangeError("the body failed");
}),
RangeError,
);
assert.ok(captured?.cleanupError instanceof Conflict);
});
});
test("a failed stop is thrown when the callback was fine", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueError(503, "the fleet could not be reached");
await assert.rejects(
client.withProfile("p1", {}, () => "done"),
DonutError,
);
});
});
test("a failed launch never runs the callback and stops nothing", async () => {
await withClient(async (client, fake) => {
fake.enqueueError(409, "PROFILE_RUNNING");
await assert.rejects(
client.withProfile("p1", {}, () => {
throw new Error("the callback must not run when the launch failed");
}),
Conflict,
);
assert.deepEqual(
fake.requests.map((sent) => sent.path),
["/v1/profiles/p1/run"],
);
});
});
test("an async callback is awaited before the browser is stopped", async () => {
await withClient(async (client, fake) => {
fake.enqueueJson(RUN_BODY);
fake.enqueueJson({ profiles: [], total: 0 });
fake.enqueueEmpty(204);
await client.withProfile("p1", {}, async () => {
await client.listProfiles();
});
assert.deepEqual(
fake.requests.map((sent) => sent.path),
["/v1/profiles/p1/run", "/v1/profiles", "/v1/profiles/p1/kill"],
);
});
});
test("a session also disposes itself", async () => {
// `withProfile` is the portable form, but a runtime with `await using` can
// hold a RunSession directly.
await withClient(async (client, fake) => {
fake.enqueueEmpty(204);
const session = new RunSession(client, "p1", RUN_BODY);
await session[Symbol.asyncDispose]();
assert.deepEqual(
fake.requests.map((sent) => sent.path),
["/v1/profiles/p1/kill"],
);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { DonutClient } from "../src/index.mts";
import { FakeDonut } from "./fake-donut.mts";
export const TOKEN = "test-token-abc123";
/** Start a fake app, point a client at it, and always shut the server down. */
export async function withClient<T>(
work: (client: DonutClient, fake: FakeDonut) => Promise<T>,
): Promise<T> {
const fake = await new FakeDonut().start();
try {
const client = new DonutClient({
token: TOKEN,
port: fake.port,
timeoutMs: 5_000,
env: {},
});
return await work(client, fake);
} finally {
await fake.stop();
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM", "ESNext.Disposable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"exactOptionalPropertyTypes": false,
"noUncheckedIndexedAccess": true,
"declaration": true,
"noEmitOnError": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": false,
"outDir": "dist",
"rootDir": "src",
"types": [],
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src/**/*.mts"]
}