diff --git a/packages/core/src/__tests__/client.test.ts b/packages/core/src/__tests__/client.test.ts new file mode 100644 index 0000000..dcd4d57 --- /dev/null +++ b/packages/core/src/__tests__/client.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'node:http'; +import { VisionLClient } from '../client.js'; +import type { ApiResponse, PageInfo } from '../types/api.js'; + +function createMockServer() { + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + const url = new URL(req.url!, 'http://localhost'); + + if (req.method === 'GET' && url.pathname === '/health') { + res.end(JSON.stringify({ status: 'ok' })); + } else if (req.method === 'POST' && url.pathname === '/pages') { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + const { url: pageUrl, alias } = JSON.parse(body); + res.end(JSON.stringify({ + ok: true, + data: { id: 'p_a1b2c3d4', url: pageUrl, alias, title: 'Test Page', status: 'active', profile: 'desktop-chrome' } + })); + }); + } else if (req.method === 'GET' && url.pathname === '/pages') { + res.end(JSON.stringify({ ok: true, data: [{ id: 'p_test', url: 'https://test.com', title: 'Test', status: 'active' }] })); + } else if (req.method === 'GET' && url.pathname.startsWith('/pages/p_test/text')) { + res.end(JSON.stringify({ ok: true, data: { text: 'Hello World' } })); + } else { + res.statusCode = 404; + res.end(JSON.stringify({ ok: false, error: { code: 'PAGE_NOT_FOUND', message: 'not found' } })); + } + }); + return server; +} + +describe('VisionLClient', () => { + let server: http.Server; + let client: VisionLClient; + + beforeAll(async () => { + server = createMockServer(); + await new Promise((resolve) => server.listen(19527, resolve)); + client = new VisionLClient('http://127.0.0.1:19527'); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('health returns true when daemon is up', async () => { + const result = await client.health(); + expect(result).toBe(true); + }); + + it('openPage returns page info', async () => { + const result = await client.openPage('https://example.com', 'demo'); + expect(result.ok).toBe(true); + expect(result.data!.id).toBe('p_a1b2c3d4'); + expect(result.data!.alias).toBe('demo'); + }); + + it('listPages returns array', async () => { + const result = await client.listPages(); + expect(result.ok).toBe(true); + expect(result.data!).toHaveLength(1); + }); + + it('text returns page content', async () => { + const result = await client.text('p_test'); + expect(result.ok).toBe(true); + expect(result.data!.text).toBe('Hello World'); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts new file mode 100644 index 0000000..beed1b2 --- /dev/null +++ b/packages/core/src/client.ts @@ -0,0 +1,85 @@ +import type { ApiResponse, PageInfo } from './types/api.js'; + +export class VisionLClient { + constructor(private baseUrl: string) {} + + private async request(method: string, path: string, body?: unknown): Promise> { + const url = `${this.baseUrl}${path}`; + const options: RequestInit = { + method, + headers: { 'Content-Type': 'application/json' }, + }; + if (body !== undefined) { + options.body = JSON.stringify(body); + } + + const response = await fetch(url, options); + const json = await response.json() as ApiResponse; + return json; + } + + async health(): Promise { + try { + const res = await this.request<{ status: string }>('GET', '/health'); + return res.ok || (res.data as any)?.status === 'ok' || (res as any).status === 'ok'; + } catch { + return false; + } + } + + async openPage(url: string, alias?: string, profile?: string): Promise> { + return this.request('POST', '/pages', { url, alias, profile }); + } + + async listPages(): Promise> { + return this.request('GET', '/pages'); + } + + async getPage(id: string): Promise> { + return this.request('GET', `/pages/${id}`); + } + + async killPage(id: string): Promise> { + return this.request('DELETE', `/pages/${id}`); + } + + async navigate(id: string, url: string): Promise> { + return this.request('POST', `/pages/${id}/navigate`, { url }); + } + + async click(id: string, selector: string): Promise> { + return this.request('POST', `/pages/${id}/click`, { selector }); + } + + async type(id: string, selector: string, text: string): Promise> { + return this.request('POST', `/pages/${id}/type`, { selector, text }); + } + + async scroll(id: string, opts: { deltaY?: number; toBottom?: boolean }): Promise> { + return this.request('POST', `/pages/${id}/scroll`, opts); + } + + async eval(id: string, code: string): Promise> { + return this.request('POST', `/pages/${id}/eval`, { code }); + } + + async wait(id: string, opts: { selector?: string; ms?: number }): Promise> { + return this.request('POST', `/pages/${id}/wait`, opts); + } + + async screenshot(id: string): Promise> { + return this.request('GET', `/pages/${id}/screenshot`); + } + + async text(id: string): Promise> { + return this.request('GET', `/pages/${id}/text`); + } + + async html(id: string): Promise> { + return this.request('GET', `/pages/${id}/html`); + } + + async getProfiles(): Promise>> { + return this.request('GET', '/profiles'); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 39c3620..b28674a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,3 +9,4 @@ export { type PermissionState, } from './types/fingerprint.js'; export { safeStringify, isValidJson } from './escape.js'; +export { VisionLClient } from './client.js';