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
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;
}