mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-14 21:55:40 +02:00
refactor: cleanup
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user