feat: add VisionLClient HTTP client for daemon communication

This commit is contained in:
2026-08-12 20:43:53 +08:00
parent 8b83fb5c53
commit 3810661098
3 changed files with 158 additions and 0 deletions
@@ -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<void>((resolve) => server.listen(19527, resolve));
client = new VisionLClient('http://127.0.0.1:19527');
});
afterAll(async () => {
await new Promise<void>((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');
});
});
+85
View File
@@ -0,0 +1,85 @@
import type { ApiResponse, PageInfo } from './types/api.js';
export class VisionLClient {
constructor(private baseUrl: string) {}
private async request<T>(method: string, path: string, body?: unknown): Promise<ApiResponse<T>> {
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<T>;
return json;
}
async health(): Promise<boolean> {
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<ApiResponse<PageInfo>> {
return this.request<PageInfo>('POST', '/pages', { url, alias, profile });
}
async listPages(): Promise<ApiResponse<PageInfo[]>> {
return this.request<PageInfo[]>('GET', '/pages');
}
async getPage(id: string): Promise<ApiResponse<PageInfo>> {
return this.request<PageInfo>('GET', `/pages/${id}`);
}
async killPage(id: string): Promise<ApiResponse<null>> {
return this.request<null>('DELETE', `/pages/${id}`);
}
async navigate(id: string, url: string): Promise<ApiResponse<{ url: string; title: string }>> {
return this.request('POST', `/pages/${id}/navigate`, { url });
}
async click(id: string, selector: string): Promise<ApiResponse<{ success: boolean }>> {
return this.request('POST', `/pages/${id}/click`, { selector });
}
async type(id: string, selector: string, text: string): Promise<ApiResponse<{ success: boolean }>> {
return this.request('POST', `/pages/${id}/type`, { selector, text });
}
async scroll(id: string, opts: { deltaY?: number; toBottom?: boolean }): Promise<ApiResponse<{ success: boolean }>> {
return this.request('POST', `/pages/${id}/scroll`, opts);
}
async eval(id: string, code: string): Promise<ApiResponse<{ result: unknown }>> {
return this.request('POST', `/pages/${id}/eval`, { code });
}
async wait(id: string, opts: { selector?: string; ms?: number }): Promise<ApiResponse<{ success: boolean }>> {
return this.request('POST', `/pages/${id}/wait`, opts);
}
async screenshot(id: string): Promise<ApiResponse<{ base64: string; mime: string }>> {
return this.request('GET', `/pages/${id}/screenshot`);
}
async text(id: string): Promise<ApiResponse<{ text: string }>> {
return this.request('GET', `/pages/${id}/text`);
}
async html(id: string): Promise<ApiResponse<{ html: string }>> {
return this.request('GET', `/pages/${id}/html`);
}
async getProfiles(): Promise<ApiResponse<Array<{ id: string; name: string }>>> {
return this.request('GET', '/profiles');
}
}
+1
View File
@@ -9,3 +9,4 @@ export {
type PermissionState,
} from './types/fingerprint.js';
export { safeStringify, isValidJson } from './escape.js';
export { VisionLClient } from './client.js';