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