diff --git a/packages/browseai/client.test.ts b/packages/browseai/client.test.ts new file mode 100644 index 000000000..5bfb06122 --- /dev/null +++ b/packages/browseai/client.test.ts @@ -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; + 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 = {}; + 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); + }); +}); diff --git a/packages/browseai/client.ts b/packages/browseai/client.ts new file mode 100644 index 000000000..f0a642628 --- /dev/null +++ b/packages/browseai/client.ts @@ -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 `. + * + * @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 = { + schema: z.ZodType; + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + body?: Record; + query?: Record; +}; + +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( + endpoint: string, + apiKey: string, + options: BrowseaiRequestOptions, +): Promise { + 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); +} diff --git a/packages/browseai/endpoints.test.ts b/packages/browseai/endpoints.test.ts new file mode 100644 index 000000000..d62a3668d --- /dev/null +++ b/packages/browseai/endpoints.test.ts @@ -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[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(), + ); + }); +}); diff --git a/packages/browseai/endpoints/index.ts b/packages/browseai/endpoints/index.ts new file mode 100644 index 000000000..773a07270 --- /dev/null +++ b/packages/browseai/endpoints/index.ts @@ -0,0 +1,12 @@ +export { + bulkRun, + createMonitor, + createWebhook, + deleteMonitor, + getStatus, + getTask, + listRobots, + listTasks, + listWebhooks, + runRobot, +} from './ops'; diff --git a/packages/browseai/endpoints/ops.ts b/packages/browseai/endpoints/ops.ts new file mode 100644 index 000000000..73224eae6 --- /dev/null +++ b/packages/browseai/endpoints/ops.ts @@ -0,0 +1,245 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BrowseaiEndpoints } from '../index'; +import { + auditPayload, + browseaiCall, + compactBody, + compactQuery, + robotPath, +} from './shared'; +import { + BrowseaiEndpointInputSchemas, + BrowseaiEndpointOutputSchemas, +} from './types'; + +export const getStatus: BrowseaiEndpoints['systemGetStatus'] = async ( + ctx, + input, +) => { + BrowseaiEndpointInputSchemas.systemGetStatus.parse(input); + const result = await browseaiCall( + ctx, + 'status', + BrowseaiEndpointOutputSchemas.systemGetStatus, + ); + await logEventFromContext( + ctx, + 'browseai.system.getStatus', + auditPayload(input, []), + 'completed', + ); + return result; +}; + +export const listRobots: BrowseaiEndpoints['robotsList'] = async ( + ctx, + input, +) => { + BrowseaiEndpointInputSchemas.robotsList.parse(input); + const result = await browseaiCall( + ctx, + 'robots', + BrowseaiEndpointOutputSchemas.robotsList, + ); + await logEventFromContext( + ctx, + 'browseai.robots.list', + { + ...auditPayload(input, []), + returned: result.robots?.items?.length ?? 0, + }, + 'completed', + ); + return result; +}; + +export const runRobot: BrowseaiEndpoints['robotsRun'] = async (ctx, input) => { + const parsed = BrowseaiEndpointInputSchemas.robotsRun.parse(input); + const result = await browseaiCall( + ctx, + robotPath(parsed.robotId, '/tasks'), + BrowseaiEndpointOutputSchemas.robotsRun, + { + method: 'POST', + body: compactBody({ + recordVideo: parsed.recordVideo, + inputParameters: parsed.inputParameters, + }), + }, + ); + await logEventFromContext( + ctx, + 'browseai.robots.run', + auditPayload(parsed, ['robotId']), + 'completed', + ); + return result; +}; + +export const bulkRun: BrowseaiEndpoints['robotsBulkRun'] = async ( + ctx, + input, +) => { + const parsed = BrowseaiEndpointInputSchemas.robotsBulkRun.parse(input); + const result = await browseaiCall( + ctx, + robotPath(parsed.robotId, '/bulk-runs'), + BrowseaiEndpointOutputSchemas.robotsBulkRun, + { + method: 'POST', + body: compactBody({ + title: parsed.title, + inputParameters: parsed.inputParameters, + }), + }, + ); + await logEventFromContext( + ctx, + 'browseai.robots.bulkRun', + auditPayload(parsed, ['robotId']), + 'completed', + ); + return result; +}; + +export const listTasks: BrowseaiEndpoints['tasksList'] = async (ctx, input) => { + const parsed = BrowseaiEndpointInputSchemas.tasksList.parse(input); + const result = await browseaiCall( + ctx, + robotPath(parsed.robotId, '/tasks'), + BrowseaiEndpointOutputSchemas.tasksList, + { + query: compactQuery({ + page: parsed.page, + pageSize: parsed.pageSize, + status: parsed.status, + robotBulkRunId: parsed.robotBulkRunId, + sort: parsed.sort, + includeRetried: parsed.includeRetried, + fromDate: parsed.fromDate, + toDate: parsed.toDate, + }), + }, + ); + await logEventFromContext( + ctx, + 'browseai.tasks.list', + auditPayload(parsed, ['robotId']), + 'completed', + ); + return result; +}; + +export const getTask: BrowseaiEndpoints['tasksGet'] = async (ctx, input) => { + const parsed = BrowseaiEndpointInputSchemas.tasksGet.parse(input); + const result = await browseaiCall( + ctx, + `${robotPath(parsed.robotId, '/tasks')}/${encodeURIComponent(parsed.taskId)}`, + BrowseaiEndpointOutputSchemas.tasksGet, + ); + await logEventFromContext( + ctx, + 'browseai.tasks.get', + auditPayload(parsed, ['robotId', 'taskId']), + 'completed', + ); + return result; +}; + +export const createMonitor: BrowseaiEndpoints['monitorsCreate'] = async ( + ctx, + input, +) => { + const parsed = BrowseaiEndpointInputSchemas.monitorsCreate.parse(input); + const result = await browseaiCall( + ctx, + robotPath(parsed.robotId, '/monitors'), + BrowseaiEndpointOutputSchemas.monitorsCreate, + { + method: 'POST', + body: compactBody({ + name: parsed.name, + inputParameters: parsed.inputParameters, + notifyOnCapturedScreenshotChange: + parsed.notifyOnCapturedScreenshotChange, + notifyOnCapturedTextChange: parsed.notifyOnCapturedTextChange, + capturedScreenshotNotificationThreshold: + parsed.capturedScreenshotNotificationThreshold, + schedule: parsed.schedule, + schedules: parsed.schedules, + }), + }, + ); + await logEventFromContext( + ctx, + 'browseai.monitors.create', + auditPayload(parsed, ['robotId']), + 'completed', + ); + return result; +}; + +export const deleteMonitor: BrowseaiEndpoints['monitorsDelete'] = async ( + ctx, + input, +) => { + const parsed = BrowseaiEndpointInputSchemas.monitorsDelete.parse(input); + const result = await browseaiCall( + ctx, + `${robotPath(parsed.robotId, '/monitors')}/${encodeURIComponent(parsed.monitorId)}`, + BrowseaiEndpointOutputSchemas.monitorsDelete, + { method: 'DELETE' }, + ); + await logEventFromContext( + ctx, + 'browseai.monitors.delete', + auditPayload(parsed, ['robotId', 'monitorId']), + 'completed', + ); + return result; +}; + +export const createWebhook: BrowseaiEndpoints['webhooksCreate'] = async ( + ctx, + input, +) => { + const parsed = BrowseaiEndpointInputSchemas.webhooksCreate.parse(input); + const result = await browseaiCall( + ctx, + robotPath(parsed.robotId, '/webhooks'), + BrowseaiEndpointOutputSchemas.webhooksCreate, + { + method: 'POST', + body: { + hookUrl: parsed.hookUrl, + eventType: parsed.eventType, + }, + }, + ); + await logEventFromContext( + ctx, + 'browseai.webhooks.create', + auditPayload(parsed, ['robotId']), + 'completed', + ); + return result; +}; + +export const listWebhooks: BrowseaiEndpoints['webhooksList'] = async ( + ctx, + input, +) => { + const parsed = BrowseaiEndpointInputSchemas.webhooksList.parse(input); + const result = await browseaiCall( + ctx, + robotPath(parsed.robotId, '/webhooks'), + BrowseaiEndpointOutputSchemas.webhooksList, + ); + await logEventFromContext( + ctx, + 'browseai.webhooks.list', + auditPayload(parsed, ['robotId']), + 'completed', + ); + return result; +}; diff --git a/packages/browseai/endpoints/shared.ts b/packages/browseai/endpoints/shared.ts new file mode 100644 index 000000000..abdd44ef5 --- /dev/null +++ b/packages/browseai/endpoints/shared.ts @@ -0,0 +1,64 @@ +import type { z } from 'zod'; +import type { BrowseaiRequestOptions } from '../client'; +import { makeBrowseaiRequest } from '../client'; + +type BrowseaiCallContext = { + key: string; +}; + +export async function browseaiCall( + ctx: BrowseaiCallContext, + endpoint: string, + schema: z.ZodType, + options: Omit, 'schema'> = {}, +): Promise { + return await makeBrowseaiRequest(endpoint, ctx.key, { + ...options, + schema, + }); +} + +export function compactBody(body: T): Partial { + const compacted: Partial = {}; + for (const key of Object.keys(body) as (keyof T)[]) { + const value = body[key]; + if (value !== undefined) { + compacted[key] = value; + } + } + return compacted; +} + +export function compactQuery< + T extends Record, +>(query: T): Partial { + const compacted: Partial = {}; + for (const key of Object.keys(query) as (keyof T)[]) { + const value = query[key]; + if (value !== undefined) { + compacted[key] = value; + } + } + return compacted; +} + +export function robotPath(robotId: string, suffix = ''): string { + return `robots/${encodeURIComponent(robotId)}${suffix}`; +} + +export function auditPayload( + input: T, + identifierKeys: readonly (keyof T & string)[], +): Partial & { fields?: string[] } { + const payload: Partial & { fields?: string[] } = {}; + for (const key of identifierKeys) { + if (input[key] !== undefined) { + payload[key] = input[key]; + } + } + const supplied = Object.keys(input).filter( + (key) => input[key as keyof T] !== undefined, + ); + if (supplied.length > 0) payload.fields = supplied; + return payload; +} diff --git a/packages/browseai/endpoints/types.ts b/packages/browseai/endpoints/types.ts new file mode 100644 index 000000000..7f008fdad --- /dev/null +++ b/packages/browseai/endpoints/types.ts @@ -0,0 +1,213 @@ +import { z } from 'zod'; +import { + BrowseaiBulkRun, + BrowseaiInputParameters, + BrowseaiMonitor, + BrowseaiRobot, + BrowseaiRobotTask, + BrowseaiSystemStatus, + BrowseaiWebhook, +} from '../schema/database'; + +export const BrowseaiWebhookEventType = z.enum([ + 'taskCapturedDataChanged', + 'taskFinished', + 'taskFinishedSuccessfully', + 'taskFinishedWithError', + 'tableExportFinishedSuccessfully', +]); + +export const BrowseaiTaskStatus = z.enum([ + 'failed', + 'successful', + 'in-progress', +]); + +const SystemGetStatusInputSchema = z.object({}); +const RobotsListInputSchema = z.object({}); + +const RobotsRunInputSchema = z.object({ + robotId: z.string().min(1), + recordVideo: z.boolean().optional(), + inputParameters: BrowseaiInputParameters.optional(), +}); + +const RobotsBulkRunInputSchema = z.object({ + robotId: z.string().min(1), + title: z.string().min(1).max(200).optional(), + inputParameters: z.array(BrowseaiInputParameters).min(1), +}); + +const TasksListInputSchema = z.object({ + robotId: z.string().min(1), + page: z.number().optional(), + pageSize: z.number().optional(), + status: BrowseaiTaskStatus.optional(), + robotBulkRunId: z.string().optional(), + sort: z.string().optional(), + includeRetried: z.boolean().optional(), + fromDate: z.number().optional(), + toDate: z.number().optional(), +}); + +const TasksGetInputSchema = z.object({ + robotId: z.string().min(1), + taskId: z.string().min(1), +}); + +const MonitorsCreateInputSchema = z.object({ + robotId: z.string().min(1), + name: z.string().min(1).max(200), + inputParameters: BrowseaiInputParameters, + notifyOnCapturedScreenshotChange: z.boolean(), + notifyOnCapturedTextChange: z.boolean(), + capturedScreenshotNotificationThreshold: z.number(), + schedule: z.string().optional(), + schedules: z + .array( + z.object({ + type: z.literal('FIXED_INTERVAL'), + everyMinutes: z.number(), + }), + ) + .optional(), +}); + +const MonitorsDeleteInputSchema = z.object({ + robotId: z.string().min(1), + monitorId: z.string().min(1), +}); + +const WebhooksCreateInputSchema = z.object({ + robotId: z.string().min(1), + hookUrl: z.string().url(), + eventType: BrowseaiWebhookEventType, +}); + +const WebhooksListInputSchema = z.object({ + robotId: z.string().min(1), +}); + +const StatusEnvelope = z + .object({ + statusCode: z.number().nullable().optional(), + messageCode: z.string().nullable().optional(), + }) + .loose(); + +const RobotsListOutputSchema = StatusEnvelope.extend({ + robots: z + .object({ + totalCount: z.number().nullable().optional(), + items: z.array(BrowseaiRobot).nullable().optional(), + }) + .loose() + .nullable() + .optional(), +}); + +const RobotTaskResultOutputSchema = StatusEnvelope.extend({ + result: BrowseaiRobotTask.nullable().optional(), +}); + +const TasksListOutputSchema = StatusEnvelope.extend({ + result: z + .object({ + robotTasks: z + .object({ + totalCount: z.number().nullable().optional(), + pageNumber: z.number().nullable().optional(), + hasMore: z.boolean().nullable().optional(), + items: z.array(BrowseaiRobotTask).nullable().optional(), + }) + .loose() + .nullable() + .optional(), + }) + .loose() + .nullable() + .optional(), +}); + +const BulkRunOutputSchema = StatusEnvelope.extend({ + result: z + .object({ + bulkRun: BrowseaiBulkRun.nullable().optional(), + }) + .loose() + .nullable() + .optional(), +}); + +const MonitorCreateOutputSchema = StatusEnvelope.extend({ + monitor: BrowseaiMonitor.nullable().optional(), +}); + +const MonitorDeleteOutputSchema = StatusEnvelope; + +const WebhookCreateOutputSchema = StatusEnvelope.extend({ + webhook: BrowseaiWebhook.nullable().optional(), +}); + +const WebhooksListOutputSchema = StatusEnvelope.extend({ + webhooks: z + .object({ + totalCount: z.number().nullable().optional(), + items: z.array(BrowseaiWebhook).nullable().optional(), + }) + .loose() + .nullable() + .optional(), +}); + +export type BrowseaiEndpointInputs = { + systemGetStatus: z.infer; + robotsList: z.infer; + robotsRun: z.infer; + robotsBulkRun: z.infer; + tasksList: z.infer; + tasksGet: z.infer; + monitorsCreate: z.infer; + monitorsDelete: z.infer; + webhooksCreate: z.infer; + webhooksList: z.infer; +}; + +export type BrowseaiEndpointOutputs = { + systemGetStatus: z.infer; + robotsList: z.infer; + robotsRun: z.infer; + robotsBulkRun: z.infer; + tasksList: z.infer; + tasksGet: z.infer; + monitorsCreate: z.infer; + monitorsDelete: z.infer; + webhooksCreate: z.infer; + webhooksList: z.infer; +}; + +export const BrowseaiEndpointInputSchemas = { + systemGetStatus: SystemGetStatusInputSchema, + robotsList: RobotsListInputSchema, + robotsRun: RobotsRunInputSchema, + robotsBulkRun: RobotsBulkRunInputSchema, + tasksList: TasksListInputSchema, + tasksGet: TasksGetInputSchema, + monitorsCreate: MonitorsCreateInputSchema, + monitorsDelete: MonitorsDeleteInputSchema, + webhooksCreate: WebhooksCreateInputSchema, + webhooksList: WebhooksListInputSchema, +} as const; + +export const BrowseaiEndpointOutputSchemas = { + systemGetStatus: BrowseaiSystemStatus, + robotsList: RobotsListOutputSchema, + robotsRun: RobotTaskResultOutputSchema, + robotsBulkRun: BulkRunOutputSchema, + tasksList: TasksListOutputSchema, + tasksGet: RobotTaskResultOutputSchema, + monitorsCreate: MonitorCreateOutputSchema, + monitorsDelete: MonitorDeleteOutputSchema, + webhooksCreate: WebhookCreateOutputSchema, + webhooksList: WebhooksListOutputSchema, +} as const; diff --git a/packages/browseai/error-handlers.test.ts b/packages/browseai/error-handlers.test.ts new file mode 100644 index 000000000..962243b1c --- /dev/null +++ b/packages/browseai/error-handlers.test.ts @@ -0,0 +1,58 @@ +import { ApiError } from 'corsair/http'; +import { errorHandlers } from './error-handlers'; + +function apiError(status: number) { + return new ApiError( + { method: 'GET', url: 'status' }, + { + url: 'https://api.browse.ai/v2/status', + ok: false, + status, + statusText: '', + body: { messageCode: 'fail' }, + }, + 'request failed', + ); +} + +function classify(error: Error): string { + for (const [name, handler] of Object.entries(errorHandlers)) { + if (handler.match(error)) return name; + } + return 'UNMATCHED'; +} + +describe('Browse AI error handlers', () => { + it('classifies 429 as rate limit', () => { + expect(classify(apiError(429))).toBe('RATE_LIMIT_ERROR'); + }); + + it('classifies 401 as auth', () => { + expect(classify(apiError(401))).toBe('AUTH_ERROR'); + }); + + it('classifies 403 as permission', () => { + expect(classify(apiError(403))).toBe('PERMISSION_ERROR'); + }); + + it('classifies 404 as not found', () => { + expect(classify(apiError(404))).toBe('NOT_FOUND_ERROR'); + }); + + it('classifies 400 as bad request', () => { + expect(classify(apiError(400))).toBe('BAD_REQUEST_ERROR'); + }); + + it('does not retry auth failures', async () => { + const result = await errorHandlers.AUTH_ERROR.handler(); + expect(result.maxRetries).toBe(0); + }); + + it('does not replay operations after a 429', async () => { + const err = apiError(429); + (err as { retryAfter?: number }).retryAfter = 2000; + const result = await errorHandlers.RATE_LIMIT_ERROR.handler(err); + expect(result.maxRetries).toBe(0); + expect(result.headersRetryAfterMs).toBe(2000); + }); +}); diff --git a/packages/browseai/error-handlers.ts b/packages/browseai/error-handlers.ts new file mode 100644 index 000000000..5ec15f011 --- /dev/null +++ b/packages/browseai/error-handlers.ts @@ -0,0 +1,73 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +/** + * Browse AI uses HTTP status on JSON envelopes (`statusCode` / `messageCode`). + * + * Every handler returns `maxRetries: 0`. `corsair/http` already retries + * idempotent 429s; replaying POST would duplicate tasks, monitors, bulk + * runs, and webhooks. + * + * | status | meaning | + * | ------ | ---------------- | + * | 400 | validation | + * | 401 | bad API key | + * | 403 | forbidden | + * | 404 | missing resource | + * | 429 | rate limited | + * + * @see https://docs.browse.ai/api/ + */ +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 429) return true; + const msg = error.message.toLowerCase(); + return msg.includes('429') || msg.includes('rate limit'); + }, + handler: async (error: Error) => { + // Transport already retries idempotent 429s. Do not replay writes. + const retryAfterMs = + error instanceof ApiError ? error.retryAfter : undefined; + return { maxRetries: 0, headersRetryAfterMs: retryAfterMs }; + }, + }, + + AUTH_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('unauthenticated'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + + PERMISSION_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 403) return true; + return error.message.toLowerCase().includes('forbidden'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + + NOT_FOUND_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 404) return true; + return error.message.toLowerCase().includes('not found'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + + BAD_REQUEST_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 400) return true; + return error.message.toLowerCase().includes('bad request'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/browseai/index.ts b/packages/browseai/index.ts new file mode 100644 index 000000000..e020cfb77 --- /dev/null +++ b/packages/browseai/index.ts @@ -0,0 +1,266 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { + bulkRun, + createMonitor, + createWebhook, + deleteMonitor, + getStatus, + getTask, + listRobots, + listTasks, + listWebhooks, + runRobot, +} from './endpoints'; +import type { + BrowseaiEndpointInputs, + BrowseaiEndpointOutputs, +} from './endpoints/types'; +import { + BrowseaiEndpointInputSchemas, + BrowseaiEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { BrowseaiSchema } from './schema'; + +export type BrowseaiPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + hooks?: InternalBrowseaiPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +/** + * Browse AI authenticates with an API key as `Authorization: Bearer`. + * + * @see https://docs.browse.ai/api/ + */ +export const browseaiAuthConfig = { + api_key: {}, +} as const satisfies PluginAuthConfig; + +export type BrowseaiContext = CorsairPluginContext< + typeof BrowseaiSchema, + BrowseaiPluginOptions, + undefined, + typeof browseaiAuthConfig +>; + +export type BrowseaiKeyBuilderContext = + KeyBuilderContext; + +export type BrowseaiBoundEndpoints = BindEndpoints< + typeof browseaiEndpointsNested +>; + +type BrowseaiEndpoint = + CorsairEndpoint< + BrowseaiContext, + BrowseaiEndpointInputs[K], + BrowseaiEndpointOutputs[K] + >; + +export type BrowseaiEndpoints = { + systemGetStatus: BrowseaiEndpoint<'systemGetStatus'>; + robotsList: BrowseaiEndpoint<'robotsList'>; + robotsRun: BrowseaiEndpoint<'robotsRun'>; + robotsBulkRun: BrowseaiEndpoint<'robotsBulkRun'>; + tasksList: BrowseaiEndpoint<'tasksList'>; + tasksGet: BrowseaiEndpoint<'tasksGet'>; + monitorsCreate: BrowseaiEndpoint<'monitorsCreate'>; + monitorsDelete: BrowseaiEndpoint<'monitorsDelete'>; + webhooksCreate: BrowseaiEndpoint<'webhooksCreate'>; + webhooksList: BrowseaiEndpoint<'webhooksList'>; +}; + +const browseaiEndpointsNested = { + system: { + getStatus, + }, + robots: { + list: listRobots, + run: runRobot, + bulkRun, + }, + tasks: { + list: listTasks, + get: getTask, + }, + monitors: { + create: createMonitor, + delete: deleteMonitor, + }, + webhooks: { + create: createWebhook, + list: listWebhooks, + }, +} as const; + +export const browseaiEndpointSchemas = { + 'system.getStatus': { + input: BrowseaiEndpointInputSchemas.systemGetStatus, + output: BrowseaiEndpointOutputSchemas.systemGetStatus, + }, + 'robots.list': { + input: BrowseaiEndpointInputSchemas.robotsList, + output: BrowseaiEndpointOutputSchemas.robotsList, + }, + 'robots.run': { + input: BrowseaiEndpointInputSchemas.robotsRun, + output: BrowseaiEndpointOutputSchemas.robotsRun, + }, + 'robots.bulkRun': { + input: BrowseaiEndpointInputSchemas.robotsBulkRun, + output: BrowseaiEndpointOutputSchemas.robotsBulkRun, + }, + 'tasks.list': { + input: BrowseaiEndpointInputSchemas.tasksList, + output: BrowseaiEndpointOutputSchemas.tasksList, + }, + 'tasks.get': { + input: BrowseaiEndpointInputSchemas.tasksGet, + output: BrowseaiEndpointOutputSchemas.tasksGet, + }, + 'monitors.create': { + input: BrowseaiEndpointInputSchemas.monitorsCreate, + output: BrowseaiEndpointOutputSchemas.monitorsCreate, + }, + 'monitors.delete': { + input: BrowseaiEndpointInputSchemas.monitorsDelete, + output: BrowseaiEndpointOutputSchemas.monitorsDelete, + }, + 'webhooks.create': { + input: BrowseaiEndpointInputSchemas.webhooksCreate, + output: BrowseaiEndpointOutputSchemas.webhooksCreate, + }, + 'webhooks.list': { + input: BrowseaiEndpointInputSchemas.webhooksList, + output: BrowseaiEndpointOutputSchemas.webhooksList, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof browseaiEndpointsNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +export const browseaiEndpointMeta = { + 'system.getStatus': { + riskLevel: 'read', + description: 'Check Browse AI task-queue status', + }, + 'robots.list': { + riskLevel: 'read', + description: 'List robots on the account', + }, + 'robots.run': { + riskLevel: 'write', + description: 'Start a robot task', + }, + 'robots.bulkRun': { + riskLevel: 'write', + description: 'Start a bulk run of robot tasks', + }, + 'tasks.list': { + riskLevel: 'read', + description: 'List tasks for a robot', + }, + 'tasks.get': { + riskLevel: 'read', + description: 'Get a robot task by id', + }, + 'monitors.create': { + riskLevel: 'write', + description: 'Create a robot monitor', + }, + 'monitors.delete': { + riskLevel: 'write', + description: 'Delete a robot monitor', + }, + 'webhooks.create': { + riskLevel: 'write', + description: 'Create a robot webhook', + }, + 'webhooks.list': { + riskLevel: 'read', + description: 'List webhooks for a robot', + }, +} as const satisfies RequiredPluginEndpointMeta; + +export type BaseBrowseaiPlugin = CorsairPlugin< + 'browseai', + typeof BrowseaiSchema, + typeof browseaiEndpointsNested, + Record, + T, + typeof defaultAuthType +>; + +export type InternalBrowseaiPlugin = BaseBrowseaiPlugin; + +export type ExternalBrowseaiPlugin = + BaseBrowseaiPlugin; + +/** + * Browse AI plugin. + * + * **No inbound webhooks.** Browse AI calls URLs you register on a robot; + * it does not POST events into Corsair. + */ +export function browseai( + incomingOptions: BrowseaiPluginOptions & T = {} as BrowseaiPluginOptions & T, +): ExternalBrowseaiPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'browseai', + authConfig: browseaiAuthConfig, + schema: BrowseaiSchema, + options, + hooks: options.hooks, + endpoints: browseaiEndpointsNested, + webhooks: {}, + endpointMeta: browseaiEndpointMeta, + endpointSchemas: browseaiEndpointSchemas, + webhookSchemas: {}, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: BrowseaiKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + if (!res) { + throw new AuthMissingError('browseai', 'api_key'); + } + return res; + } + + throw new AuthMissingError('browseai', ctx.authType); + }, + } satisfies InternalBrowseaiPlugin; +} + +export type { + BrowseaiEndpointInputs, + BrowseaiEndpointOutputs, +} from './endpoints/types'; diff --git a/packages/browseai/jest.config.cjs b/packages/browseai/jest.config.cjs new file mode 100644 index 000000000..c0213f2d7 --- /dev/null +++ b/packages/browseai/jest.config.cjs @@ -0,0 +1,43 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: ['**/*.test.ts'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + rootDir: '../..', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/browseai/package.json b/packages/browseai/package.json new file mode 100644 index 000000000..2bd3fdef8 --- /dev/null +++ b/packages/browseai/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/browseai", + "version": "0.1.0", + "description": "Browse AI plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "browseai", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/browseai/plugin.test.ts b/packages/browseai/plugin.test.ts new file mode 100644 index 000000000..7761b36c9 --- /dev/null +++ b/packages/browseai/plugin.test.ts @@ -0,0 +1,87 @@ +import { AuthMissingError } from 'corsair/core'; +import { browseai, browseaiAuthConfig, browseaiEndpointSchemas } from './index'; + +jest.mock('corsair/core', () => { + class AuthMissingError extends Error { + constructor(plugin: string, authType: string) { + super(`Missing ${authType} auth for ${plugin}`); + this.name = 'AuthMissingError'; + } + } + + return { AuthMissingError, logEventFromContext: jest.fn() }; +}); + +function keyBuilderOf(plugin: { keyBuilder?: unknown }) { + const keyBuilder = plugin.keyBuilder; + if (typeof keyBuilder !== 'function') { + throw new Error('keyBuilder is not registered'); + } + return keyBuilder as (ctx: unknown, source: string) => Promise; +} + +function flattenEndpoints(plugin: ReturnType): string[] { + const groups = plugin.endpoints as unknown as Record< + string, + Record + >; + return Object.entries(groups) + .flatMap(([group, ops]) => Object.keys(ops).map((op) => `${group}.${op}`)) + .sort(); +} + +const OPS = [ + 'monitors.create', + 'monitors.delete', + 'robots.bulkRun', + 'robots.list', + 'robots.run', + 'system.getStatus', + 'tasks.get', + 'tasks.list', + 'webhooks.create', + 'webhooks.list', +]; + +describe('browseai plugin registration', () => { + const plugin = browseai(); + + it('exposes the documented operations', () => { + expect(flattenEndpoints(plugin)).toEqual(OPS); + }); + + it('registers api_key only', () => { + expect(Object.keys(browseaiAuthConfig)).toEqual(['api_key']); + expect(plugin.options?.authType).toBe('api_key'); + }); + + it('has input and output schemas for every endpoint', () => { + expect(Object.keys(browseaiEndpointSchemas).sort()).toEqual(OPS); + }); + + it('registers no webhooks', () => { + expect(plugin.webhooks).toEqual({}); + }); + + it('throws AuthMissingError when no key is stored', async () => { + const keyBuilder = keyBuilderOf(plugin); + await expect( + keyBuilder( + { + authType: 'api_key', + keys: { get_api_key: async () => null }, + }, + 'endpoint', + ), + ).rejects.toBeInstanceOf(AuthMissingError); + }); + + it('returns a direct key when provided', async () => { + const keyed = browseai({ key: 'direct-token' }); + const token = await keyBuilderOf(keyed)( + { authType: 'api_key' }, + 'endpoint', + ); + expect(token).toBe('direct-token'); + }); +}); diff --git a/packages/browseai/schema.test.ts b/packages/browseai/schema.test.ts new file mode 100644 index 000000000..522c5bff0 --- /dev/null +++ b/packages/browseai/schema.test.ts @@ -0,0 +1,154 @@ +/** + * Validates that every key Browse AI documents is declared in schema/database.ts. + * Entities are `.loose()`, so `safeParse` alone would never notice a missing field. + * + * Key names come from https://docs.browse.ai/api/ (OpenAPI 3.1). + */ + +import { BrowseaiSchema } from './schema'; +import { + BrowseaiBulkRun, + BrowseaiCapturedScreenshot, + BrowseaiMonitor, + BrowseaiRobot, + BrowseaiRobotInputParameter, + BrowseaiRobotTask, + BrowseaiSystemStatus, + BrowseaiWebhook, +} from './schema/database'; + +const ROBOT_KEYS = ['id', 'name', 'createdAt', 'inputParameters']; +const PARAM_KEYS = [ + 'type', + 'name', + 'label', + 'required', + 'encrypted', + 'defaultValue', + 'value', + 'min', + 'max', + 'pattern', + 'options', +]; +const TASK_KEYS = [ + 'id', + 'inputParameters', + 'robotId', + 'status', + 'runByUserId', + 'robotBulkRunId', + 'runByTaskMonitorId', + 'runByAPI', + 'createdAt', + 'startedAt', + 'finishedAt', + 'userFriendlyError', + 'triedRecordingVideo', + 'videoUrl', + 'videoRemovedAt', + 'retriedOriginalTaskId', + 'retriedTaskId', + 'retriedByTaskId', + 'capturedDataTemporaryUrl', + 'capturedTexts', + 'capturedScreenshots', + 'capturedLists', +]; +const SCREENSHOT_KEYS = [ + 'id', + 'name', + 'src', + 'width', + 'height', + 'x', + 'y', + 'deviceScaleFactor', + 'full', + 'comparedToScreenshotId', + 'diffImageSrc', + 'changePercentage', + 'diffThreshold', + 'fileRemovedAt', +]; +const MONITOR_KEYS = [ + 'id', + 'name', + 'status', + 'pausedReason', + 'inputParameters', + 'schedules', + 'schedule', + 'notifyOnCapturedScreenshotChange', + 'notifyOnCapturedTextChange', + 'capturedScreenshotNotificationThreshold', + 'createdAt', + 'pausedAt', + 'updatedAt', +]; +const WEBHOOK_KEYS = ['id', 'url', 'webhookEvent', 'createdAt']; +const BULK_RUN_KEYS = [ + 'id', + 'title', + 'status', + 'tasksCount', + 'successfulTasks', + 'failedTasks', + 'robotId', + 'createdAt', +]; +const STATUS_KEYS = ['statusCode', 'messageCode', 'tasksQueueStatus']; + +describe('Browse AI schema', () => { + it('declares a semver version', () => { + expect(BrowseaiSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('mirrors the entities the plugin persists', () => { + expect(Object.keys(BrowseaiSchema.entities).sort()).toEqual([ + 'bulkRun', + 'monitor', + 'robot', + 'robotTask', + 'systemStatus', + 'webhook', + ]); + }); + + describe('every documented key is declared', () => { + const cases: [string, { shape: Record }, string[]][] = [ + ['robot', BrowseaiRobot, ROBOT_KEYS], + ['param', BrowseaiRobotInputParameter, PARAM_KEYS], + ['task', BrowseaiRobotTask, TASK_KEYS], + ['screenshot', BrowseaiCapturedScreenshot, SCREENSHOT_KEYS], + ['monitor', BrowseaiMonitor, MONITOR_KEYS], + ['webhook', BrowseaiWebhook, WEBHOOK_KEYS], + ['bulkRun', BrowseaiBulkRun, BULK_RUN_KEYS], + ['status', BrowseaiSystemStatus, STATUS_KEYS], + ]; + + for (const [label, entity, capturedKeys] of cases) { + it(`declares every ${label} key`, () => { + const declared = Object.keys(entity.shape); + const undeclared = capturedKeys.filter((k) => !declared.includes(k)); + expect(undeclared).toEqual([]); + }); + } + }); + + it('accepts a robot listed without extra fields', () => { + expect(BrowseaiRobot.safeParse({ id: 'r1', name: 'Bot' }).success).toBe( + true, + ); + }); + + it('accepts null for nullable task fields', () => { + expect( + BrowseaiRobotTask.safeParse({ + id: 't1', + finishedAt: null, + userFriendlyError: null, + }).success, + ).toBe(true); + }); +}); diff --git a/packages/browseai/schema/database.ts b/packages/browseai/schema/database.ts new file mode 100644 index 000000000..e47b9fd01 --- /dev/null +++ b/packages/browseai/schema/database.ts @@ -0,0 +1,184 @@ +import { z } from 'zod'; + +/** + * Field names match official JSON keys. + * https://docs.browse.ai/api/ + */ + +const S = z.string().nullable().optional(); +const N = z.number().nullable().optional(); +const B = z.boolean().nullable().optional(); + +export const BrowseaiSelectParameterOption = z + .object({ + label: S, + value: S, + }) + .loose(); +export type BrowseaiSelectParameterOption = z.infer< + typeof BrowseaiSelectParameterOption +>; + +export const BrowseaiRobotInputParameter = z + .object({ + type: S, + name: S, + label: S, + required: B, + encrypted: B, + defaultValue: z + .union([z.string(), z.number(), z.array(z.string())]) + .nullable() + .optional(), + value: z + .union([z.string(), z.number(), z.array(z.string()), z.null()]) + .optional(), + min: N, + max: N, + pattern: S, + options: z.array(BrowseaiSelectParameterOption).nullable().optional(), + }) + .loose(); +export type BrowseaiRobotInputParameter = z.infer< + typeof BrowseaiRobotInputParameter +>; + +export const BrowseaiInputParameters = z.record( + z.string(), + z.union([z.string(), z.number(), z.array(z.string())]), +); +export type BrowseaiInputParameters = z.infer; + +export const BrowseaiRobot = z + .object({ + id: S, + name: S, + createdAt: N, + inputParameters: z.array(BrowseaiRobotInputParameter).nullable().optional(), + }) + .loose(); +export type BrowseaiRobot = z.infer; + +export const BrowseaiCapturedTexts = z.record( + z.string(), + z.union([z.string(), z.number(), z.boolean()]).nullable(), +); +export type BrowseaiCapturedTexts = z.infer; + +export const BrowseaiCapturedScreenshot = z + .object({ + id: S, + name: S, + src: S, + width: N, + height: N, + x: N, + y: N, + deviceScaleFactor: N, + full: S, + comparedToScreenshotId: S, + diffImageSrc: S, + changePercentage: N, + diffThreshold: N, + fileRemovedAt: N, + }) + .loose(); +export type BrowseaiCapturedScreenshot = z.infer< + typeof BrowseaiCapturedScreenshot +>; + +export const BrowseaiRobotTask = z + .object({ + id: S, + inputParameters: BrowseaiInputParameters.nullable().optional(), + robotId: S, + status: S, + runByUserId: S, + robotBulkRunId: S, + runByTaskMonitorId: S, + runByAPI: B, + createdAt: N, + startedAt: N, + finishedAt: N, + userFriendlyError: S, + triedRecordingVideo: B, + videoUrl: S, + videoRemovedAt: N, + retriedOriginalTaskId: S, + retriedTaskId: S, + retriedByTaskId: S, + capturedDataTemporaryUrl: S, + capturedTexts: BrowseaiCapturedTexts.nullable().optional(), + capturedScreenshots: z + .record(z.string(), BrowseaiCapturedScreenshot) + .nullable() + .optional(), + capturedLists: z + .record(z.string(), z.array(BrowseaiCapturedTexts)) + .nullable() + .optional(), + }) + .loose(); +export type BrowseaiRobotTask = z.infer; + +export const BrowseaiFixedIntervalSchedule = z + .object({ + type: S, + everyMinutes: N, + }) + .loose(); +export type BrowseaiFixedIntervalSchedule = z.infer< + typeof BrowseaiFixedIntervalSchedule +>; + +export const BrowseaiMonitor = z + .object({ + id: S, + name: S, + status: S, + pausedReason: S, + inputParameters: BrowseaiInputParameters.nullable().optional(), + schedules: z.array(BrowseaiFixedIntervalSchedule).nullable().optional(), + schedule: S, + notifyOnCapturedScreenshotChange: B, + notifyOnCapturedTextChange: B, + capturedScreenshotNotificationThreshold: N, + createdAt: N, + pausedAt: N, + updatedAt: N, + }) + .loose(); +export type BrowseaiMonitor = z.infer; + +export const BrowseaiWebhook = z + .object({ + id: S, + url: S, + webhookEvent: S, + createdAt: N, + }) + .loose(); +export type BrowseaiWebhook = z.infer; + +export const BrowseaiBulkRun = z + .object({ + id: S, + title: S, + status: S, + tasksCount: N, + successfulTasks: N, + failedTasks: N, + robotId: S, + createdAt: N, + }) + .loose(); +export type BrowseaiBulkRun = z.infer; + +export const BrowseaiSystemStatus = z + .object({ + statusCode: N, + messageCode: S, + tasksQueueStatus: S, + }) + .loose(); +export type BrowseaiSystemStatus = z.infer; diff --git a/packages/browseai/schema/index.ts b/packages/browseai/schema/index.ts new file mode 100644 index 000000000..e3cbab331 --- /dev/null +++ b/packages/browseai/schema/index.ts @@ -0,0 +1,22 @@ +import { + BrowseaiBulkRun, + BrowseaiMonitor, + BrowseaiRobot, + BrowseaiRobotTask, + BrowseaiSystemStatus, + BrowseaiWebhook, +} from './database'; + +export const BrowseaiSchema = { + version: '1.0.0', + entities: { + bulkRun: BrowseaiBulkRun, + monitor: BrowseaiMonitor, + robot: BrowseaiRobot, + robotTask: BrowseaiRobotTask, + systemStatus: BrowseaiSystemStatus, + webhook: BrowseaiWebhook, + }, +} as const; + +export * from './database'; diff --git a/packages/browseai/tsconfig.json b/packages/browseai/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/browseai/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/browseai/tsup.config.ts b/packages/browseai/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/browseai/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index fbbf973f2..eec69a807 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -76,6 +76,7 @@ export const BaseProviders = [ 'botpress', 'bouncer', 'box', + 'browseai', 'bugsnag', 'cal', 'calendly', @@ -271,6 +272,7 @@ export const ProviderDisplayNames = { botpress: 'Botpress', bouncer: 'Bouncer', box: 'Box', + browseai: 'Browse AI', bugsnag: 'BugSnag', cal: 'Cal', calendly: 'Calendly', @@ -473,6 +475,7 @@ export type AllProviders = | 'botpress' | 'bouncer' | 'box' + | 'browseai' | 'bugsnag' | 'cal' | 'calendly' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6118de71..5ce52d7f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1867,6 +1867,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/browseai: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/bugsnag: devDependencies: '@types/jest': @@ -6797,11 +6821,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}