Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions packages/browseai/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Transport: Bearer token, JSON POST, official v2 base.
* Credentials here are fictional.
*/
import { z } from 'zod';
import { BROWSEAI_API_BASE, makeBrowseaiRequest } from './client';

const AnyObject = z.object({}).loose();

let captured:
| {
url: string;
method: string;
headers: Record<string, string>;
body?: string;
}
| undefined;

const realFetch = global.fetch;
afterEach(() => {
global.fetch = realFetch;
});

let fetchCalls = 0;

function mockFetch(payload: unknown, status = 200) {
captured = undefined;
fetchCalls = 0;
global.fetch = (async (url: unknown, init?: RequestInit) => {
fetchCalls += 1;
const headers: Record<string, string> = {};
const raw = init?.headers;
if (raw instanceof Headers) {
raw.forEach((value, key) => {
headers[key.toLowerCase()] = value;
});
}
captured = {
url: String(url),
method: init?.method ?? 'GET',
headers,
body: typeof init?.body === 'string' ? init.body : undefined,
};
return {
ok: status < 400,
status,
statusText: status < 400 ? 'OK' : 'Error',
url: String(url),
headers: new Headers({ 'Content-Type': 'application/json' }),
json: async () => payload,
text: async () => JSON.stringify(payload),
};
}) as typeof global.fetch;
}

describe('makeBrowseaiRequest', () => {
it('hits the documented v2 base', async () => {
mockFetch({ tasksQueueStatus: 'OK' });
await makeBrowseaiRequest('status', 'tok', { schema: AnyObject });
expect(captured?.url.startsWith(`${BROWSEAI_API_BASE}/status`)).toBe(true);
});

it('sends the API key as Bearer', async () => {
mockFetch({});
await makeBrowseaiRequest('robots', 'secret-key', { schema: AnyObject });
expect(captured?.headers.authorization).toBe('Bearer secret-key');
});

it('POSTs JSON bodies', async () => {
mockFetch({ statusCode: 200, result: { id: 't1' } });
await makeBrowseaiRequest('robots/r1/tasks', 'tok', {
method: 'POST',
body: { recordVideo: false },
schema: AnyObject,
});
expect(captured?.method).toBe('POST');
expect(captured?.headers['content-type']).toContain('application/json');
expect(captured?.body).toBe('{"recordVideo":false}');
});

it('parses the response with the given schema', async () => {
mockFetch({ tasksQueueStatus: 'OK' });
const out = await makeBrowseaiRequest('status', 'tok', {
schema: z.object({ tasksQueueStatus: z.string() }),
});
expect(out.tasksQueueStatus).toBe('OK');
});

it('rejects a response that misses the schema', async () => {
mockFetch({ nope: true });
await expect(
makeBrowseaiRequest('status', 'tok', {
schema: z.object({ tasksQueueStatus: z.string() }),
}),
).rejects.toThrow();
});

it('does not retry POST on 429', async () => {
mockFetch({ messageCode: 'rate_limited' }, 429);
await expect(
makeBrowseaiRequest('robots/r1/tasks', 'tok', {
method: 'POST',
body: { recordVideo: false },
schema: AnyObject,
}),
).rejects.toThrow();
expect(fetchCalls).toBe(1);
});
});
83 changes: 83 additions & 0 deletions packages/browseai/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type {
ApiRequestOptions,
OpenAPIConfig,
RateLimitConfig,
} from 'corsair/http';
import { request } from 'corsair/http';
import type { z } from 'zod';

/**
* Official v2 base. Auth is `Authorization: Bearer <api key>`.
*
* @see https://docs.browse.ai/api/
*/
export const BROWSEAI_API_BASE = 'https://api.browse.ai/v2';

export const BROWSEAI_RATE_LIMIT_CONFIG: RateLimitConfig = {
enabled: true,
maxRetries: 3,
initialRetryDelay: 1000,
backoffMultiplier: 2,
headerNames: {
retryAfter: 'retry-after',
},
};

const BROWSEAI_NO_RETRY: RateLimitConfig = {
...BROWSEAI_RATE_LIMIT_CONFIG,
enabled: false,
maxRetries: 0,
};

export type BrowseaiRequestOptions<T> = {
schema: z.ZodType<T>;
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: Record<string, string | number | boolean | object>;
query?: Record<string, string | number | boolean | undefined>;
};

function buildConfig(apiKey: string): OpenAPIConfig {
return {
BASE: BROWSEAI_API_BASE,
VERSION: '2',
WITH_CREDENTIALS: false,
CREDENTIALS: 'omit',
TOKEN: apiKey,
HEADERS: {
Accept: 'application/json',
},
};
}

function retryConfigFor(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
): RateLimitConfig {
// POST/PATCH/PUT create tasks, monitors, bulk runs, and webhooks.
// Retrying those on 429 would duplicate work. GET and DELETE are safe.
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
return BROWSEAI_NO_RETRY;
}
return BROWSEAI_RATE_LIMIT_CONFIG;
}

export async function makeBrowseaiRequest<T>(
endpoint: string,
apiKey: string,
options: BrowseaiRequestOptions<T>,
): Promise<T> {
const { schema, method = 'GET', body, query } = options;
const isWrite = method === 'POST' || method === 'PUT' || method === 'PATCH';

const requestOptions: ApiRequestOptions = {
method,
url: endpoint,
body: isWrite ? body : undefined,
mediaType: isWrite ? 'application/json; charset=utf-8' : undefined,
query,
};

const raw = await request(buildConfig(apiKey), requestOptions, {
rateLimitConfig: retryConfigFor(method),
});
return schema.parse(raw);
}
203 changes: 203 additions & 0 deletions packages/browseai/endpoints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { logEventFromContext } from 'corsair/core';
import {
bulkRun,
createMonitor,
createWebhook,
deleteMonitor,
getStatus,
getTask,
listRobots,
listTasks,
listWebhooks,
runRobot,
} from './endpoints';
import { browseaiEndpointMeta, browseaiEndpointSchemas } from './index';

jest.mock('corsair/core', () => ({
logEventFromContext: jest.fn(async () => undefined),
}));

const mockLogEvent = logEventFromContext as jest.MockedFunction<
typeof logEventFromContext
>;

type Ctx = Parameters<typeof getStatus>[0];

function makeCtx() {
return {
key: 'test-token',
options: { authType: 'api_key' },
} as unknown as Ctx;
}

let captured: { url: string; method: string; body?: string } | undefined;

const realFetch = global.fetch;
afterEach(() => {
global.fetch = realFetch;
mockLogEvent.mockClear();
});

function mockFetch(payload: unknown, status = 200) {
captured = undefined;
global.fetch = (async (url: unknown, init?: RequestInit) => {
captured = {
url: String(url),
method: init?.method ?? 'GET',
body: typeof init?.body === 'string' ? init.body : undefined,
};
return {
ok: status < 400,
status,
statusText: 'OK',
url: String(url),
headers: new Headers({ 'Content-Type': 'application/json' }),
json: async () => payload,
text: async () => JSON.stringify(payload),
};
}) as unknown as typeof global.fetch;
}

function pathAndQuery(): { path: string; query: URLSearchParams } {
const url = new URL(captured?.url ?? 'http://invalid');
return { path: url.pathname, query: url.searchParams };
}

describe('Browse AI endpoints', () => {
it('system.getStatus calls GET /status', async () => {
mockFetch({
statusCode: 200,
messageCode: 'success',
tasksQueueStatus: 'OK',
});
const out = await getStatus(makeCtx(), {});
expect(pathAndQuery().path).toBe('/v2/status');
expect(out.tasksQueueStatus).toBe('OK');
});

it('robots.list calls GET /robots', async () => {
mockFetch({
statusCode: 200,
robots: { totalCount: 1, items: [{ id: 'r1', name: 'Bot' }] },
});
const out = await listRobots(makeCtx(), {});
expect(pathAndQuery().path).toBe('/v2/robots');
expect(out.robots?.items?.[0]?.id).toBe('r1');
});

it('robots.run POSTs /robots/{id}/tasks', async () => {
mockFetch({ statusCode: 200, result: { id: 't1', robotId: 'r1' } });
const out = await runRobot(makeCtx(), {
robotId: 'r1',
recordVideo: true,
inputParameters: { originUrl: 'https://example.com' },
});
expect(captured?.method).toBe('POST');
expect(pathAndQuery().path).toBe('/v2/robots/r1/tasks');
expect(captured?.body).toContain('originUrl');
expect(out.result?.id).toBe('t1');
});

it('robots.bulkRun POSTs /robots/{id}/bulk-runs', async () => {
mockFetch({
statusCode: 200,
result: { bulkRun: { id: 'b1', robotId: 'r1' } },
});
await bulkRun(makeCtx(), {
robotId: 'r1',
title: 'Batch',
inputParameters: [{ originUrl: 'https://example.com' }],
});
expect(captured?.method).toBe('POST');
expect(pathAndQuery().path).toBe('/v2/robots/r1/bulk-runs');
expect(captured?.body).toContain('Batch');
});

it('tasks.list paginates GET /robots/{id}/tasks', async () => {
mockFetch({
statusCode: 200,
result: {
robotTasks: { totalCount: 0, pageNumber: 2, hasMore: false, items: [] },
},
});
await listTasks(makeCtx(), {
robotId: 'r1',
page: 2,
pageSize: 10,
status: 'successful',
includeRetried: false,
});
const { path, query } = pathAndQuery();
expect(path).toBe('/v2/robots/r1/tasks');
expect(query.get('page')).toBe('2');
expect(query.get('pageSize')).toBe('10');
expect(query.get('status')).toBe('successful');
expect(query.get('includeRetried')).toBe('false');
});

it('tasks.get calls GET /robots/{id}/tasks/{taskId}', async () => {
mockFetch({ statusCode: 200, result: { id: 't1' } });
await getTask(makeCtx(), { robotId: 'r1', taskId: 't1' });
expect(pathAndQuery().path).toBe('/v2/robots/r1/tasks/t1');
});

it('monitors.create POSTs documented monitor fields', async () => {
mockFetch({ statusCode: 200, monitor: { id: 'm1', name: 'Watch' } });
await createMonitor(makeCtx(), {
robotId: 'r1',
name: 'Watch',
inputParameters: { originUrl: 'https://example.com' },
notifyOnCapturedScreenshotChange: true,
notifyOnCapturedTextChange: false,
capturedScreenshotNotificationThreshold: 15,
schedule: 'FREQ=DAILY;INTERVAL=1',
});
expect(captured?.method).toBe('POST');
expect(pathAndQuery().path).toBe('/v2/robots/r1/monitors');
expect(captured?.body).toContain('notifyOnCapturedScreenshotChange');
expect(captured?.body).toContain('FREQ=DAILY');
});

it('monitors.delete DELETEs /robots/{id}/monitors/{monitorId}', async () => {
mockFetch({ statusCode: 200, messageCode: 'success' });
await deleteMonitor(makeCtx(), { robotId: 'r1', monitorId: 'm1' });
expect(captured?.method).toBe('DELETE');
expect(pathAndQuery().path).toBe('/v2/robots/r1/monitors/m1');
});

it('webhooks.create POSTs hookUrl and eventType', async () => {
mockFetch({
statusCode: 200,
webhook: { id: 'w1', url: 'https://example.com/hook' },
});
await createWebhook(makeCtx(), {
robotId: 'r1',
hookUrl: 'https://example.com/hook',
eventType: 'taskFinished',
});
expect(captured?.method).toBe('POST');
expect(pathAndQuery().path).toBe('/v2/robots/r1/webhooks');
expect(captured?.body).toContain('hookUrl');
expect(captured?.body).toContain('taskFinished');
});

it('webhooks.list calls GET /robots/{id}/webhooks', async () => {
mockFetch({
statusCode: 200,
webhooks: { totalCount: 0, items: [] },
});
await listWebhooks(makeCtx(), { robotId: 'r1' });
expect(pathAndQuery().path).toBe('/v2/robots/r1/webhooks');
});

it('rejects a response that misses the output schema', async () => {
mockFetch({ tasksQueueStatus: 1 });
await expect(getStatus(makeCtx(), {})).rejects.toThrow();
});

it('covers every registered operation', () => {
expect(Object.keys(browseaiEndpointMeta).sort()).toEqual(
Object.keys(browseaiEndpointSchemas).sort(),
);
});
});
Loading
Loading